def test_sum2(): # Check it returns the same results with casadi and numpy a = np.array([[1, 2, 3], [1, 2, 3]]) b = cas.SX(a) assert np.all(np.sum(a) == cas.DM(np.sum(b))) assert np.all(np.sum(a, axis=1) == cas.DM(np.sum(b, axis=1)))
def test_norm_2D(): a = np.arange(9).reshape(3, 3) cas_a = cas.DM(a) assert np.linalg.norm(cas_a) == np.linalg.norm(a) assert np.all(np.linalg.norm(cas_a, axis=0) == np.linalg.norm(a, axis=0)) assert np.all(np.linalg.norm(cas_a, axis=1) == np.linalg.norm(a, axis=1))
def test_cross_2D_input_first_axis(): a = np.tile(np.array([1, 1, 1]), (3, 1)).T b = np.tile(np.array([1, 2, 3]), (3, 1)).T cas_a = cas.DM(a) cas_b = cas.DM(b) correct_result = np.cross(a, b, axis=0) cas_correct_result = cas.DM(correct_result) assert np.all(np.cross(a, cas_b, axis=0) == cas_correct_result) assert np.all(np.cross(cas_a, b, axis=0) == cas_correct_result) assert np.all(np.cross(cas_a, cas_b, axis=0) == cas_correct_result)
def test_cross_1D_input(): a = np.array([1, 1, 1]) b = np.array([1, 2, 3]) cas_a = cas.DM(a) cas_b = cas.DM(b) correct_result = np.cross(a, b) cas_correct_result = cas.DM(correct_result) assert np.all(np.cross(a, cas_b) == cas_correct_result) assert np.all(np.cross(cas_a, b) == cas_correct_result) assert np.all(np.cross(cas_a, cas_b) == cas_correct_result)
def test_basic_logicals_numpy(): a = np.array([True, True, False, False]) b = np.array([True, False, True, False]) assert np.all( a & b == np.array([True, False, False, False]) )
def test_cas_vector(): output = reflect_over_XZ_plane(cas.DM(vec)) assert isinstance(output, cas.DM) assert np.all( output == np.array([0, -1, 2]) )
def test_numpy_equivalency_1D(): inputs = [1, 2] a = array(inputs) a_np = np.array(inputs) assert np.all(a == a_np)
def test_where_casadi(): a = cas.GenDM_ones(4) b = 2 * cas.GenDM_ones(4) c = np.where(cas.DM([1, 0, 1, 0]), a, b) assert np.all(c == cas.DM([1, 2, 1, 2]))
def test_reshape(): a = np.array([ [1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12], ]) b = cas.DM(a) test_inputs = [ -1, (4, 3), (3, 4), (12, 1), (1, 12), (-1), (12, -1), (-1, 12), ] for i in test_inputs: ra = np.reshape(a, i) rb = np.reshape(b, i) if len(ra.shape) == 1: ra = ra.reshape(-1, 1) assert np.all(ra == rb)
def test_array_numpy_equivalency_2D(): inputs = [[1, 2], [3, 4]] a = array(inputs) a_np = np.array(inputs) assert np.all(a == a_np)
def test_where_numpy(): a = np.ones(4) b = 2 * np.ones(4) c = np.where(np.array([True, False, True, False]), a, b) assert np.all(c == np.array([1, 2, 1, 2]))
def test_np_square(): assert np.all( reflect_over_XZ_plane(square) == np.array([ [0, -1, 2], [3, -4, 5], [6, -7, 8] ]) )
def test_interpolated_model_at_vector(): model = interpolated_model() x_data = { "x1": np.array([1.5, 2.5]), "x2": np.array([2.5, 3.5]), } assert np.all( model(x_data) == pytest.approx(underlying_function_2D( *x_data.values())))
def test_np_rectangular_tall(): assert np.all( reflect_over_XZ_plane(rectangular_tall) == np.array([ [0, -1, 2], [3, -4, 5], [6, -7, 8], [9, -10, 11], ]) )
def test_cas_square(): output = reflect_over_XZ_plane(cas.DM(square)) assert isinstance(output, cas.DM) assert np.all( output == np.array([ [0, -1, 2], [3, -4, 5], [6, -7, 8] ]) )
def test_cas_rectangular_tall(): output = reflect_over_XZ_plane(cas.DM(rectangular_tall)) assert isinstance(output, cas.DM) assert np.all( output == np.array([ [0, -1, 2], [3, -4, 5], [6, -7, 8], [9, -10, 11], ]) )
def test_interpn_linear_multiple_samples(): ### NumPy test def value_func_3d(x, y, z): return 2 * x + 3 * y - z x = np.linspace(0, 5, 10) y = np.linspace(0, 5, 20) z = np.linspace(0, 5, 30) points = (x, y, z) values = value_func_3d(*np.meshgrid(*points, indexing="ij")) point = np.array([ [2.21, 3.12, 1.15], [3.42, 0.81, 2.43] ]) value = np.interpn( points, values, point ) assert np.all( value == pytest.approx( value_func_3d( *[ point[:, i] for i in range(point.shape[1]) ] ) ) ) assert len(value) == 2 ### CasADi test point = cas.DM(point) value = np.interpn( points, values, point ) value_actual = value_func_3d( *[ np.array(point[:, i]) for i in range(point.shape[1]) ] ) for i in range(len(value)): assert value[i] == pytest.approx(float(value_actual[i])) assert value.shape == (2,)
def subject_to(self, constraint: Union[cas.MX, bool, List], # TODO add scale ) -> cas.MX: """ Initialize a new equality or inequality constraint(s). Args: constraint: A constraint that you want to hold true at the optimum. Inequality example: >>> x = opti.variable() >>> opti.subject_to(x >= 5) Equality example; also showing that you can directly constrain functions of variables: >>> x = opti.variable() >>> f = np.sin(x) >>> opti.subject_to(f == 0.5) You can also pass in a list of multiple constraints using list syntax. For example: >>> x = opti.variable() >>> opti.subject_to([ >>> x >= 5, >>> x <= 10 >>> ]) Returns: The dual variable associated with the new constraint. If the `constraint` input is a list, returns a list of dual variables. """ # Determine whether you're dealing with a single (possibly vectorized) constraint or a list of constraints. # If the latter, recursively apply them. if type(constraint) in (list, tuple): return [ self.subject_to(each_constraint) # return the dual of each constraint for each_constraint in constraint ] # If it's a proper constraint (MX-type and non-parametric), # pass it into the parent class Opti formulation and be done with it. if isinstance(constraint, cas.MX) and not self.advanced.is_parametric(constraint): super().subject_to(constraint) dual = self.dual(constraint) return dual else: # Constraint is not valid because it is not MX type or is parametric. try: constraint_satisfied = np.all(self.value(constraint)) except: raise TypeError(f"""Opti.subject_to could not determine the truthiness of your constraint, and it doesn't appear to be a symbolic type or a boolean type. You supplied the following constraint: {constraint}""") if constraint_satisfied or self.ignore_violated_parametric_constraints: # If the constraint(s) always evaluates True (e.g. if you enter "5 > 3"), skip it. # This allows you to toggle frozen variables without causing problems with setting up constraints. return None # dual of an always-true constraint doesn't make sense to evaluate. else: # If any of the constraint(s) are always False (e.g. if you enter "5 < 3"), raise an error. # This indicates that the problem is infeasible as-written, likely because the user has frozen too # many decision variables using the Opti.variable(freeze=True) syntax. raise RuntimeError(f"""The problem is infeasible due to a constraint that always evaluates False. This can happen if you've frozen too many decision variables, leading to an overconstrained problem.""")
def test_roll_casadi_2d(): a = np.array([[1, 2, 3], [4, 5, 6]]) b = cas.SX(a) assert np.all(cas.DM(np.roll(b, 1, axis=1)) == np.roll(a, 1, axis=1))
def test_roll_casadi(): b = np.array([[3, 1, 2]]) a = cas.SX(b) assert np.all(cas.DM(np.roll(a, 1)) == b)
def test_roll_onp(): a = [1, 2, 3] b = [3, 1, 2] assert np.all(np.roll(a, 1) == b)
def test_can_convert_DM_to_ndarray(): c = cas.DM([1, 2, 3]) n = np.array(c) assert np.all(n == np.array([1, 2, 3]))
def __init__( self, model: Callable[ [Union[np.ndarray, Dict[str, np.ndarray]], Dict[str, float]], np.ndarray], x_data: Union[np.ndarray, Dict[str, np.ndarray]], y_data: np.ndarray, parameter_guesses: Dict[str, float], parameter_bounds: Dict[str, tuple] = None, residual_norm_type: str = "L2", fit_type: str = "best", weights: np.ndarray = None, put_residuals_in_logspace: bool = False, verbose=True, ): """ Fits an analytical model to n-dimensional unstructured data using an automatic-differentiable optimization approach. Args: model: The model that you want to fit your dataset to. This is a callable with syntax f(x, p) where: * x is a dict of dependent variables. Same format as x_data [dict of 1D ndarrays of length n]. * If the model is one-dimensional (e.g. f(x1) instead of f(x1, x2, x3...)), you can instead interpret x as a 1D ndarray. (If you do this, just give `x_data` as an array.) * p is a dict of parameters. Same format as param_guesses [dict with syntax param_name:param_value]. Model should return a 1D ndarray of length n. Basically, if you've done it right: >>> model(x_data, parameter_guesses) should evaluate to a 1D ndarray where each x_data is mapped to something analogous to y_data. (The fit will likely be bad at this point, because we haven't yet optimized on param_guesses - but the types should be happy.) Model should use aerosandbox.numpy operators. The model is not allowed to make any in-place changes to the input `x`. The most common way this manifests itself is if someone writes something to the effect of `x += 3` or similar. Instead, write `x = x + 3`. x_data: Values of the dependent variable(s) in the dataset to be fitted. This is a dictionary; syntax is { var_name:var_data}. * If the model is one-dimensional (e.g. f(x1) instead of f(x1, x2, x3...)), you can instead supply x_data as a 1D ndarray. (If you do this, just treat `x` as an array in your model, not a dict.) y_data: Values of the independent variable in the dataset to be fitted. [1D ndarray of length n] parameter_guesses: a dict of fit parameters. Syntax is {param_name:param_initial_guess}. * Parameters will be initialized to the values set here; all parameters need an initial guess. * param_initial_guess is a float; note that only scalar parameters are allowed. parameter_bounds: Optional: a dict of bounds on fit parameters. Syntax is {"param_name":(min, max)}. * May contain only a subset of param_guesses if desired. * Use None to represent one-sided constraints (i.e. (None, 5)). residual_norm_type: What error norm should we minimize to optimize the fit parameters? Options: * "L1": minimize the L1 norm or sum(abs(error)). Less sensitive to outliers. * "L2": minimize the L2 norm, also known as the Euclidian norm, or sqrt(sum(error ** 2)). The default. * "Linf": minimize the L_infinty norm or max(abs(error)). More sensitive to outliers. fit_type: Should we find the model of best fit (i.e. the model that minimizes the specified residual norm), or should we look for a model that represents an upper/lower bound on the data (useful for robust surrogate modeling, so that you can put bounds on modeling error): * "best": finds the model of best fit. Usually, this is what you want. * "upper bound": finds a model that represents an upper bound on the data (while still trying to minimize the specified residual norm). * "lower bound": finds a model that represents a lower bound on the data (while still trying to minimize the specified residual norm). weights: Optional: weights for data points. If not supplied, weights are assumed to be uniform. * Weights are automatically normalized. [1D ndarray of length n] put_residuals_in_logspace: Whether to optimize using the logarithmic error as opposed to the absolute error (useful for minimizing percent error). Note: If any model outputs or data are negative, this will raise an error! verbose: Should the progress of the optimization solve that is part of the fitting be displayed? See `aerosandbox.Opti.solve(verbose=)` syntax for more details. Returns: A model in the form of a FittedModel object. Some things you can do: >>> y = FittedModel(x) # evaluate the FittedModel at new x points >>> FittedModel.parameters # directly examine the optimal values of the parameters that were found >>> FittedModel.plot() # plot the fit """ super().__init__() ##### Prepare all inputs, check types/sizes. ### Flatten all inputs def flatten(input): return np.array(input).flatten() try: x_data = {k: flatten(v) for k, v in x_data.items()} x_data_is_dict = True except AttributeError: # If it's not a dict or dict-like, assume it's a 1D ndarray dataset x_data = flatten(x_data) x_data_is_dict = False y_data = flatten(y_data) n_datapoints = np.length(y_data) ### Handle weighting if weights is None: weights = np.ones(n_datapoints) else: weights = flatten(weights) sum_weights = np.sum(weights) if sum_weights <= 0: raise ValueError("The weights must sum to a positive number!") if np.any(weights < 0): raise ValueError( "No entries of the weights vector are allowed to be negative!") weights = weights / np.sum( weights) # Normalize weights so that they sum to 1. ### Check format of parameter_bounds input if parameter_bounds is None: parameter_bounds = {} for param_name, v in parameter_bounds.items(): if param_name not in parameter_guesses.keys(): raise ValueError( f"A parameter name (key = \"{param_name}\") in parameter_bounds was not found in parameter_guesses." ) if not np.length(v) == 2: raise ValueError( "Every value in parameter_bounds must be a tuple in the format (lower_bound, upper_bound). " "For one-sided bounds, use None for the unbounded side.") ### If putting residuals in logspace, check positivity if put_residuals_in_logspace: if not np.all(y_data > 0): raise ValueError( "You can't fit a model with residuals in logspace if y_data is not entirely positive!" ) ### Check dimensionality of inputs to fitting algorithm relevant_inputs = { "y_data": y_data, "weights": weights, } try: relevant_inputs.update(x_data) except TypeError: relevant_inputs.update({"x_data": x_data}) for key, value in relevant_inputs.items(): # Check that the length of the inputs are consistent series_length = np.length(value) if not series_length == n_datapoints: raise ValueError( f"The supplied data series \"{key}\" has length {series_length}, but y_data has length {n_datapoints}." ) ##### Formulate and solve the fitting optimization problem ### Initialize an optimization environment opti = Opti() ### Initialize the parameters as optimization variables params = {} for param_name, param_initial_guess in parameter_guesses.items(): if param_name in parameter_bounds: params[param_name] = opti.variable( init_guess=param_initial_guess, lower_bound=parameter_bounds[param_name][0], upper_bound=parameter_bounds[param_name][1], ) else: params[param_name] = opti.variable( init_guess=param_initial_guess, ) ### Evaluate the model at the data points you're trying to fit x_data_original = copy.deepcopy( x_data ) # Make a copy of x_data so that you can determine if the model did in-place operations on x and tattle on the user. try: y_model = model(x_data, params) # Evaluate the model except Exception: raise Exception(""" There was an error when evaluating the model you supplied with the x_data you supplied. Likely possible causes: * Your model() does not have the call syntax model(x, p), where x is the x_data and p are parameters. * Your model should take in p as a dict of parameters, but it does not. * Your model assumes x is an array-like but you provided x_data as a dict, or vice versa. See the docstring of FittedModel() if you have other usage questions or would like to see examples. """) try: ### If the model did in-place operations on x_data, throw an error x_data_is_unchanged = np.all(x_data == x_data_original) except ValueError: x_data_is_unchanged = np.all([ x_series == x_series_original for x_series, x_series_original in zip(x_data, x_data_original) ]) if not x_data_is_unchanged: raise TypeError( "model(x_data, parameter_guesses) did in-place operations on x, which is not allowed!" ) if y_model is None: # Make sure that y_model actually returned something sensible raise TypeError( "model(x_data, parameter_guesses) returned None, when it should've returned a 1D ndarray." ) ### Compute how far off you are (error) if not put_residuals_in_logspace: error = y_model - y_data else: y_model = np.fmax( y_model, 1e-300 ) # Keep y_model very slightly always positive, so that log() doesn't NaN. error = np.log(y_model) - np.log(y_data) ### Set up the optimization problem to minimize some norm(error), which looks different depending on the norm used: if residual_norm_type.lower() == "l1": # Minimize the L1 norm abs_error = opti.variable(init_guess=0, n_vars=np.length( y_data)) # Make the abs() of each error entry an opt. var. opti.subject_to([ abs_error >= error, abs_error >= -error, ]) opti.minimize(np.sum(weights * abs_error)) elif residual_norm_type.lower() == "l2": # Minimize the L2 norm opti.minimize(np.sum(weights * error**2)) elif residual_norm_type.lower( ) == "linf": # Minimize the L-infinity norm linf_value = opti.variable( init_guess=0 ) # Make the value of the L-infinity norm an optimization variable opti.subject_to([ linf_value >= weights * error, linf_value >= -weights * error ]) opti.minimize(linf_value) else: raise ValueError("Bad input for the 'residual_type' parameter.") ### Add in the constraints specified by fit_type, which force the model to stay above / below the data points. if fit_type == "best": pass elif fit_type == "upper bound": opti.subject_to(y_model >= y_data) elif fit_type == "lower bound": opti.subject_to(y_model <= y_data) else: raise ValueError("Bad input for the 'fit_type' parameter.") ### Solve sol = opti.solve(verbose=verbose) ##### Construct a FittedModel ### Create a vector of solved parameters params_solved = {} for param_name in params: try: params_solved[param_name] = sol.value(params[param_name]) except: params_solved[param_name] = np.NaN ### Store all the data and inputs self.model = model self.x_data = x_data self.y_data = y_data self.parameters = params_solved self.parameter_guesses = parameter_guesses self.parameter_bounds = parameter_bounds self.residual_norm_type = residual_norm_type self.fit_type = fit_type self.weights = weights self.put_residuals_in_logspace = put_residuals_in_logspace
def test_diff(): a = np.arange(100) assert np.all(np.diff(a) == pytest.approx(1))
def test_cumsum(): n = np.arange(6).reshape((3, 2)) c = cas.DM(n) assert np.all(np.cumsum(n) == np.array([0, 1, 3, 6, 10, 15]))
def test_interpolated_model_at_vector(): model = interpolated_model() assert np.all( model(np.array([1.5, 2.5, 3.5])) == pytest.approx(underlying_function_1D(np.array([1.5, 2.5, 3.5]))) )
def test_np_vector(): assert np.all( reflect_over_XZ_plane(vec) == np.array([0, -1, 2]) )
def test_np_vector_2D_wide(): assert np.all( reflect_over_XZ_plane(np.expand_dims(vec, 0)) == np.array([0, -1, 2]) )
def test_invertability_of_diff_trapz(): a = np.sin(np.arange(10)) assert np.all(np.trapz(np.diff(a)) == pytest.approx(np.diff(np.trapz(a))))