Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 59 additions & 83 deletions astropy/modeling/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
from ..extern.six.moves import zip as izip
from ..extern.six.moves import range

from .parameters import Parameter, InputParameterError
from .parameters import (Parameter, InputParameterError, _tofloat)

__all__ = ['Model', 'ParametricModel', 'SummedCompositeModel',
'SerialCompositeModel', 'LabeledInput', 'Parametric1DModel',
Expand Down Expand Up @@ -179,8 +179,13 @@ class Model(object):
fittable = False
linear = True

def __init__(self, param_dim=1):
def __init__(self, param_dim=1, **params):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems fine; though now that parameter initial values are passed all the way to the Model base class, I wonder if it shouldn't also handle setting those initial values a la ParametricModel._initialize_parameters. That would allow eliminating slight oddities like those in in the projection models where you have, for example,

def __init__(self, mu=0.0, gamma=0.0):
    super(Sky2Pix_AZP, self).__init__(mu=mu, gamma=gamma)
    self.mu = mu
    self.gamma = gamma

Now that initial parameter values are checked by the base class, the repetition of this kind of boilerplate suggests that setting the parameters should be handled in Model.__init__ as well. The only difference between Model._initialize_parameters and ParametricModel._initialize_parameters is that the latter would also initialize the ._parameters array.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though building further on that point, I wonder if then all models could have a ._parameters array. This would, I think, simplify the implementation of Parameter a bit and reduce the difference between ParametricModel and any other type of model. I think the only thing that should really distinguish ParametricModel is its support for fit constraints.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am increasingly uncomfortable with the fact that depending on the base class models treat parameters in a different way. This makes the code hard to maintain and hard to understand because one needs to understand both ways. To illustrate what I mean:

for a ParametricModel

In [13]: g1=models.Gaussian1D(1,2,3)

In [14]: g1.__dict__
Out[14]: 
{'_constraints': {},
 '_eqcons': [],
 '_ineqcons': [],
 '_param_dim': 1,
 '_param_metrics': {u'amplitude': (slice(0, 1, None), ()),
  u'mean': (slice(1, 2, None), ()),
  u'stddev': (slice(2, 3, None), ())},
 '_param_total_size': 3,
 '_parameters': array([ 1.,  2.,  3.])}

for a model which subclasses directly Model

azp=models.Pix2Sky_AZP(1,2)

In [18]: azp.__dict__
Out[18]: 
{'_gamma': 0.034906585039886591,
 '_mu': 1.0,
 '_param_dim': 1,
 '_param_metrics': {u'gamma': (slice(1, 2, None), ()),
  u'mu': (slice(0, 1, None), ())},
 '_param_total_size': 2}

Non-fittable models own their parameters, while fittable models own ._parameters.

Treating parameters in a uniform way would be much preferable but I don't think we should make all models have ._parameters and behave like fittable models do now.

Another reason why implementing Parameter as a descriptor and defining model parameters as class variables is not good is that in this way Parameter can have only attributes which are the same for all instances. This makes it harder to have Parameter.value or Parameter.unit or any other attribute unless it applies to all instances. Essentially these have to be set on the model as is now done with value.

I think a better way would be to have a regular Parameter object which holds its value and any other attributes specified when the class is initialized. And have a ParameterDescriptor to access them.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first part--"depending on the base class models treat parameters in a different way"--I agree with. The second part I'm not so sure. I don't know what you mean by a "ParameterDescriptor". Parameter is already a descriptor and I don't see the point in throwing on more classes. Maybe one thing that would improve flexibility and simply the code is provide an internally used interface whereby individual model types can inform parameter instances bound to a model how to access the actual parameter values stored on the model. IIRC I already did that by having Parameter instances using getattr on the model they are bound to, but I don't remember exactly. I'd have to check the code. But I still feel like making the Parameter instances themselves store the value is asking for trouble. I still see the Parameter interface as merely an API convenience, and not something that should be intrinsic to the data models that individual Model types use to store their parameters.

In other words, I feel like a Model should be responsible for handling its own data. Not some other extra class.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@embray I was suggesting renaming the current Parameter class to ParameterDescriptor (or something else) and having a regular Parameter class which stores the value and other information if needed. This new parameter class can subclass Quantity. Why is it a bad idea to store the value of a parameter in Parameter instances owned by the model? I see it as equivalent to storing quantities with the option of adding more attributes if needed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we were to make Parameter a special subclass of Quantity (I'm not really sure how I feel about that--Parameter really serves a different purpose as being purely information about a model--how, for example, is a constraint relevant to a stand-alone "Parameter" instance except in the context of fitting a model?)

But even if we did that one part of the design of the Parameter class is that it can be used as a descriptor or as a concrete object. That way when defining a model, the author doesn't have to write:

class MyModel(Model):
    a = ParameterDescriptor(...)
    b = ParameterDescriptor(...)

they only have to remember one interface:

class MyModel(Model):
    a = Parameter(...)
    b = Parameter(...)

I don't think it makes the interface that much more complicated, because the __new__ for Parameter (or maybe it's the __init__--I forget) quickly determines whether this is a "concrete" parameter instance or descriptor for a parameter.

self._param_dim = param_dim
if params:
self._param_metrics, self._param_total_size = self._create_param_metrics(params)
else:
self._param_metrics = None
self._param_total_size = 0

@property
def param_dim(self):
Expand Down Expand Up @@ -220,11 +225,9 @@ def param_sets(self):

This is an array where each column represents one parameter set.
"""

parameters = [getattr(self, attr) for attr in self.param_names]
values = [par.value for par in parameters]
shapes = [par.shape for par in parameters]
n_dims = np.asarray([len(p.shape) for p in parameters])
values = [getattr(self, attr).value for attr in self.param_names]
shapes = [self._param_metrics[name][1] for name in self.param_names]
n_dims = np.asarray([len(shape) for shape in shapes])

if (n_dims > 1).any():
if () in shapes:
Expand Down Expand Up @@ -280,6 +283,39 @@ def copy(self):
def __call__(self):
raise NotImplementedError("Subclasses should implement this")

def _create_param_metrics(self, params):
_param_metrics = {}
total_size = 0
for name in self.param_names:
#is there a use case for this???
if params.get(name) is None:
# parameters that were not supplied at all or that have
# defaults of None should attempt to use the default provided
# by their Parameter descriptor
params[name] = getattr(self, name).default
value, shape = _tofloat(params[name])

if shape == ():
param_len = 1
else:
param_len = len(value)
#if param_len < self.param_dim:
if self.param_dim > 1 and param_len != self.param_dim:
raise InputParameterError("Parameter {0} value inconsistent with n_models attribute {1}".format(name, self.param_dim))
param_size = np.size(value)
param_shape = shape

if self.param_dim == 1:
param_shape = shape
elif self.param_dim > 1:
_, param_shape = _tofloat(value[0])
else:
raise ValueError("Model param_dim must be 1 or greater.")

param_slice = slice(total_size, total_size + param_size)
_param_metrics[name] = (param_slice, param_shape)
total_size += param_size
return _param_metrics, total_size

class ParametricModel(Model):
"""
Expand Down Expand Up @@ -376,36 +412,24 @@ def __init__(self, **kwargs):
eqcons = kwargs.pop('eqcons', None)
ineqcons = kwargs.pop('ineqcons', None)

# Pop off the param_dims
param_dim = kwargs.pop('param_dim', None)
# Pop off param_dim
param_dim = kwargs.pop('param_dim', 1)

# Remaining keyword args are either parameter values or invalid
# Parameter values must be passed in as keyword arguments in order to
# distinguish them
params = kwargs

# Determine the number of parameter sets: This will be based
# on the size of any parameters whose values have been specified
# or the default of 1 is used
# This loop also checks that all the supplied parameter names are
# valid
param_names = set(self.param_names)

max_param_dim = 1
for name, value in params.items():
if param_dim is None:
# Determine the best param_dims, if not already specified,
# based on the sizes of the input parameter values
max_param_dim = max(max_param_dim, np.size(value))

for name in params:
if name not in param_names:
raise TypeError(
"Unrecognized parameter: {0}".format(name))

if param_dim is None:
param_dim = max_param_dim

super(ParametricModel, self).__init__(param_dim=param_dim)
super(ParametricModel, self).__init__(param_dim=param_dim, **params)

# Initialize the constraints for each parameter
if eqcons is None:
Expand Down Expand Up @@ -496,12 +520,18 @@ def __getattr__(self, attr):
param_slice, param_shape = self._param_metrics[param_name]
value = self._parameters[param_slice]
if self.param_dim == 1:
return value[0]
else:
if param_shape:
return value.reshape(param_shape)
else:
return value[0]
else:
if param_shape:
param_size = len(value) // self.param_dim
return [value[param_size * i:param_size * i +
param_size].reshape(param_shape)
for i in range(self.param_dim)]
else:
return value

raise AttributeError(attr)

Expand All @@ -510,7 +540,6 @@ def __setattr__(self, attr, value):
hasattr(self, '_param_metrics')):
param_name = attr[1:]
if param_name in self._param_metrics:
# TODO: Maybe handle exception on invalid input shape
param_slice = self._param_metrics[param_name][0]
self._parameters[param_slice] = np.array(value).ravel()
return
Expand Down Expand Up @@ -575,65 +604,12 @@ def set_joint_parameters(self, jparams):
def _initialize_parameters(self, params):
"""
Initialize the _parameters array that stores raw parameter values for
all parameter sets for use with vectorized fitting algorithms; on
all parameter sets for use with fitting algorithms; on
ParametricModels the _param_name attributes actually just reference
slices of this array.
"""

# First we need to determine how much array space is needed by all the
# parameters based on the number of parameters, the shape each input
# parameter, and the param_dim
self._param_metrics = {}
total_size = 0
for name in self.param_names:

if params.get(name) is None:
# parameters that were not supplied at all or that have
# defaults of None should attempt to use the default provided
# by their Parameter descriptor
params[name] = getattr(self, name).default

value = params[name]

param_size = np.size(value)
param_shape = np.shape(value)

if self.param_dim == 1:
pass
elif self.param_dim > 1:
if param_size == 1:
param_size = self.param_dim
# Update the value for this param to the new repeated
# version
value = params[name] = np.repeat(value, param_size)
param_shape = value.shape
else:
raise ValueError("Model param_dim must be 1 or greater.")

if (param_size > 1 and param_size != self.param_dim and
len(value) != self.param_dim):
# The len(value) == self.param_dim case is a special case
# (see #1680) where the parameter has compound values (like [1,
# 2]) but we're passing in two (or more) param sets, so we want
# to make sure a value like [[1, 2], [3, 4]] is interpreted as
# having values for 2 param sets, not 4.

# For now all parameter values must have a number of elements
# equal to param_dim (the number of param sets) or it is
# invalid. The *only* exception is, again, a scalar value is
# allowed to be repeated across param sets
raise InputParameterError(
"The input value for {0!r} has too many elements for "
"param_dim={1}. Parameter values must either match the "
"model's param_dim in size, or may be a scalar value, in "
"which case the value is repeated accross parameter "
"sets.".format(name, self.param_dim))

param_slice = slice(total_size, total_size + param_size)
self._param_metrics[name] = (param_slice, param_shape)
total_size += param_size

self._parameters = np.empty(total_size, dtype=np.float64)

self._parameters = np.empty(self._param_total_size, dtype=np.float64)

# Now set the parameter values (this will also fill
# self._parameters)
Expand Down Expand Up @@ -847,7 +823,7 @@ class SerialCompositeModel(_CompositeModel):
>>> import numpy as np
>>> from astropy.modeling import models, LabeledInput, SerialCompositeModel
>>> y, x = np.mgrid[:5, :5]
>>> rotation = models.MatrixRotation2D(angle=23.5)
>>> rotation = models.RotateByAngle2D(angle=23.5)
>>> offset_x = models.Shift(-4.23)
>>> offset_y = models.Shift(2)
>>> labeled_input = LabeledInput([x, y], ["x", "y"])
Expand Down
8 changes: 4 additions & 4 deletions astropy/modeling/functional_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ class Gaussian1D(Parametric1DModel):
mean = Parameter('mean')
stddev = Parameter('stddev')

def __init__(self, amplitude, mean, stddev, **constraints):
def __init__(self, amplitude, mean, stddev, param_dim=1, **constraints):
try:
param_dim = len(amplitude)
except TypeError:
Expand Down Expand Up @@ -297,7 +297,7 @@ def __init__(self, offsets, param_dim=1):

self._offsets = offsets

super(Shift, self).__init__(param_dim=param_dim)
super(Shift, self).__init__(param_dim=param_dim, offsets=offsets)

def inverse(self):
if self.param_dim == 1:
Expand Down Expand Up @@ -339,7 +339,7 @@ def __init__(self, factors, param_dim=1):

self._factors = factors

super(Scale, self).__init__(param_dim=param_dim)
super(Scale, self).__init__(param_dim=param_dim, factors=factors)

def inverse(self):
if self.param_dim == 1:
Expand Down Expand Up @@ -1249,7 +1249,7 @@ def custom_model_1d(func, func_fit_deriv=None):

Create an instance of the custom model and evaluate it:

>>> model = SineModel()
>>> model = SineModel(1., 1)
>>> model(0.25)
1.0

Expand Down
23 changes: 12 additions & 11 deletions astropy/modeling/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ class Parameter(object):
model : object
an instance of a Model class; this should only be used internally for
creating bound Parameters

"""

# See the _nextid classmethod
Expand All @@ -120,7 +121,6 @@ def __init__(self, name, description='', default=None, getter=None,
self._default_max = max

self._order = None
self._shape = None
self._model = model

# The getter/setter functions take one or two arguments: The first
Expand All @@ -139,7 +139,7 @@ def __init__(self, name, description='', default=None, getter=None,

if model is not None:
try:
_, self._shape = self._validate_value(model, self.value)
_, _shape = self._validate_value(model, self.value)
except AttributeError:
# This can happen if the paramter's value has not been set yet
pass
Expand All @@ -159,9 +159,13 @@ def __get__(self, obj, objtype):

def __set__(self, obj, value):
value, shape = self._validate_value(obj, value)
if obj.param_dim != 1:
# If multiple parameter sets, then value is a list of parameters.
# use the shape of the first one to compare with the shape of the original parameter
_, shape = _tofloat(value[0])
# Compare the shape against the previous value's shape, if it exists
if hasattr(obj, self._attr):
current_shape = getattr(obj, self.name).shape
current_shape = obj._param_metrics[self.name][1]
if shape != current_shape:
raise InputParameterError(
"Input value for parameter '{0}' does not have the "
Expand Down Expand Up @@ -265,13 +269,7 @@ def value(self, val):
val = self._setter(val)
setattr(self._model, self._attr, val)
raise AttributeError('Cannot set a value on a parameter definition')

@property
def shape(self):
"""The shape of this parameter's value array."""

return self._shape


@property
def size(self):
"""The size of this parameter's value array."""
Expand Down Expand Up @@ -415,7 +413,10 @@ def _validate_value(self, model, value):
if model is None:
return

param_dim = model.param_dim
try:
param_dim = model.param_dim
except AttributeError:
return
if param_dim == 1:
# Just validate the value with _tofloat
return _tofloat(value)
Expand Down
24 changes: 18 additions & 6 deletions astropy/modeling/polynomial.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,13 @@ def __init__(self, degree, n_inputs=1, n_outputs=1, param_dim=1,
self._n_inputs = n_inputs
self._n_outputs = n_outputs

if params:
if not params:
for name in self.param_names:
if param_dim == 1:
params[name] = 0.0
else:
params[name] = [0.0] * param_dim
else:
p = params.get('c0', params.get('c0_0'))
if isinstance(p, collections.Sequence):
n_params = len(p)
Expand All @@ -130,14 +136,14 @@ def __init__(self, degree, n_inputs=1, n_outputs=1, param_dim=1,

self._validate_params(**params)

super(PolynomialModel, self).__init__(param_dim=param_dim)
super(PolynomialModel, self).__init__(param_dim=param_dim, **params)

for name, value in params.items():
setattr(self, name, value)

@property
def degree(self):
"""TODO: Docstring for me"""
"""Return the degree of the polynomial."""

return self._degree

Expand Down Expand Up @@ -248,8 +254,14 @@ def __init__(self, x_degree, y_degree, x_domain=None, x_window=None,
self.x_window = x_window
self.y_window = y_window
self._param_names = self._generate_coeff_names()

if params:

if not params:
for name in self.param_names:
if param_dim == 1:
params[name] = 0.0
else:
params[name] = [0.0] * param_dim
else:
p = params.get('c0_0')
if isinstance(p, collections.Sequence):
n_params = len(p)
Expand All @@ -268,7 +280,7 @@ def __init__(self, x_degree, y_degree, x_domain=None, x_window=None,

self._validate_params(**params)

super(OrthoPolynomialBase, self).__init__(param_dim=param_dim)
super(OrthoPolynomialBase, self).__init__(param_dim=param_dim, **params)

for name, value in params.items():
setattr(self, name, value)
Expand Down
Loading