def calc_interaction_product(site_fractions): """Calculate the interaction product for sublattice site fractions **Callers should take care that the site fractions correspond to constituents in sorted order, since there's an order-dependent subtraction.** Parameters ---------- site_fractions : List[List[float]] List of site fractions for each sublattice. The list should a ragged 2d list of shape (sublattices, site fractions). Returns ------- Union[float, List[float]] A scalar for binary interactions and a list of 3 floats for ternary interactions Examples -------- >>> # interaction product for an (A) site_fractions >>> calc_interaction_product([[1.0]]) # doctest: +ELLIPSIS 1.0 >>> # interaction product for [(A,B), (A,B)(A)] site fractions that are equal >>> calc_interaction_product([[0.5, 0.5]]) # doctest: +ELLIPSIS 0.0 >>> calc_interaction_product([[0.5, 0.5], 1]) # doctest: +ELLIPSIS 0.0 >>> # interaction product for an [(A,B)] site_fractions >>> calc_interaction_product([[0.1, 0.9]]) # doctest: +ELLIPSIS -0.8 >>> # interaction product for an [(A,B)(A,B)] site_fractions >>> calc_interaction_product([[0.2, 0.8], [0.4, 0.6]]) # doctest: +ELLIPSIS 0.12 >>> # ternary case, (A,B,C) interaction >>> calc_interaction_product([[0.333, 0.333, 0.334]]) [0.333, 0.333, 0.334] >>> # ternary 2SL case, (A,B,C)(A) interaction >>> calc_interaction_product([[0.333, 0.333, 0.334], 1.0]) [0.333, 0.333, 0.334] """ # config is the list of site fractions for each sublattice, e.g. [[0.25, 0.25, 0.5], 1] for an [[A,B,C], A] site_fractions is_ternary = interaction_test(site_fractions, 3) if not is_ternary: prod = 1.0 for subl in site_fractions: if isinstance(subl, list) and len(subl) == 2: # must be in sorted order!! prod *= subl[0] - subl[1] return prod else: # we need to generate v_i, v_j, or v_k for the ternary case, which v we are calculating depends on the parameter order prod = [1, 1, 1] # product for V_I, V_J, V_K for subl in site_fractions: if isinstance(subl, list) and (len(subl) >= 3): muggianu_correction = (1 - sum(subl)) / len(subl) for i in range(len(subl)): prod[i] *= subl[i] + muggianu_correction return [float(p) for p in prod]
def calc_interaction_product(site_fractions): """Calculate the interaction product for sublattice configurations Parameters ---------- site_fractions : list List of sublattice configurations. *Sites on each sublattice be in order with respect to the elements in the sublattice.* The list should be 3d of (configurations, sublattices, values) Returns ------- list List of interaction products, Z, for each sublattice Examples -------- >>> # interaction product for an (A) configuration >>> calc_interaction_product([[1.0]]) # doctest: +ELLIPSIS [1.0] >>> # interaction product for [(A,B), (A,B)(A)] configurations that are equal >>> calc_interaction_product([[[0.5, 0.5]], [[0.5, 0.5], 1]]) # doctest: +ELLIPSIS [0.0, 0.0] >>> # interaction product for an [(A,B)] configuration >>> calc_interaction_product([[[0.1, 0.9]]]) # doctest: +ELLIPSIS [-0.8] >>> # interaction product for an [(A,B)(A,B)] configuration >>> calc_interaction_product([[[0.2, 0.8], [0.4, 0.6]]]) # doctest: +ELLIPSIS [0.12] >>> # ternary case, (A,B,C) interaction >>> calc_interaction_product([[[0.333, 0.333, 0.334]]]) [[0.333, 0.333, 0.334]] >>> # ternary 2SL case, (A,B,C)(A) interaction >>> calc_interaction_product([[[0.333, 0.333, 0.334], 1.0]]) [[0.333, 0.333, 0.334]] """ interaction_product = [] # config is the list of site fractions for each sublattice, e.g. [[0.25, 0.25, 0.5], 1] for an [[A,B,C], A] configuration for config in site_fractions: is_ternary = interaction_test(config, 3) if not is_ternary: prod = 1.0 for subl in config: if isinstance(subl, list) and len(subl) == 2: # must be in sorted order!! prod *= subl[0] - subl[1] interaction_product.append(prod) else: # we need to generate v_i, v_j, or v_k for the ternary case, which v we are calculating depends on the parameter order prod = [1, 1, 1] # product for V_I, V_J, V_K for subl in config: if isinstance(subl, list) and (len(subl) >= 3): muggianu_correction = (1 - sum(subl)) / len(subl) for i in range(len(subl)): prod[i] *= subl[i] + muggianu_correction interaction_product.append([float(p) for p in prod]) return interaction_product
def test_interaction_detection(): """interaction_test should correctly calculate interactions for different candidate configurations""" no_interaction_configurations = [ ('A', 'B', 'AL', 'ABKEJF'), ( 'A', 'A', 'A', ), ('A', ), ('ABCDEDFG', ), ('AL', ), ('AL', 'ZN'), ] for config in no_interaction_configurations: assert interaction_test(config) == False binary_configurations = [ (('A', 'B'), ), (('A', 'B'), ('A', 'B')), (('AL', 'ZN'), 'A'), (('AL', 'ZN'), 'AB'), (('AL', 'ZN'), 'ABC'), (('AL', 'ZN'), 'NI'), (('AL', 'ZN'), 'NI', 'NI'), (('AL', 'NI'), ('AL', 'NI')), ] for config in binary_configurations: assert interaction_test(config) == True assert interaction_test(config, 2) == True assert interaction_test(config, 3) == False ternary_configurations = [ (('A', 'B', 'C'), ), (('AL', 'BR', 'CL'), ), (('ALAEF', 'BREFAEF', 'CFEFAL'), ), (('A', 'B', 'C'), ('A', 'B', 'ZN')), (('AL', 'CR', 'ZN'), 'A'), (('AL', 'CR', 'ZN'), 'AB'), (('AL', 'CR', 'ZN'), 'ABC'), ( ('AL', 'CR', 'ZN'), 'NI', ), (('AL', 'CR', 'ZN'), 'NI', 'NI'), (('AL', 'CR', 'NI'), ('AL', 'CR', 'NI')), ] for config in ternary_configurations: assert interaction_test(config) == True assert interaction_test(config, 2) == False assert interaction_test(config, 3) == True
def fit_formation_energy(dbf, comps, phase_name, configuration, symmetry, datasets, ridge_alpha=None, aicc_phase_penalty=None, features=None): """ Find suitable linear model parameters for the given phase. We do this by successively fitting heat capacities, entropies and enthalpies of formation, and selecting against criteria to prevent overfitting. The "best" set of parameters minimizes the error without overfitting. Parameters ---------- dbf : Database pycalphad Database. Partially complete, so we know what degrees of freedom to fix. comps : [str] Names of the relevant components. phase_name : str Name of the desired phase for which the parameters will be found. configuration : ndarray Configuration of the sublattices for the fitting procedure. symmetry : [[int]] Symmetry of the sublattice configuration. datasets : PickleableTinyDB All the datasets desired to fit to. ridge_alpha : float Value of the :math:`\\alpha` hyperparameter used in ridge regression. Defaults to 1.0e-100, which should be degenerate with ordinary least squares regression. For now, the parameter is applied to all features. aicc_feature_factors : dict Map of phase name to feature to a multiplication factor for the AICc's parameter penalty. features : dict Maps "property" to a list of features for the linear model. These will be transformed from "GM" coefficients e.g., {"CPM_FORM": (v.T*sympy.log(v.T), v.T**2, v.T**-1, v.T**3)} (Default value = None) Returns ------- dict {feature: estimated_value} """ aicc_feature_factors = aicc_phase_penalty if aicc_phase_penalty is not None else {} if interaction_test(configuration): _log.debug('ENDMEMBERS FROM INTERACTION: %s', endmembers_from_interaction(configuration)) fitting_steps = (["CPM_FORM", "CPM_MIX"], ["SM_FORM", "SM_MIX"], ["HM_FORM", "HM_MIX"]) else: # We are only fitting an endmember; no mixing data needed fitting_steps = (["CPM_FORM"], ["SM_FORM"], ["HM_FORM"]) # create the candidate models and fitting steps if features is None: features = OrderedDict([("CPM_FORM", (v.T * sympy.log(v.T), v.T**2, v.T**-1, v.T**3)), ("SM_FORM", (v.T,)), ("HM_FORM", (sympy.S.One,)), ]) # dict of {feature, [candidate_models]} candidate_models_features = build_candidate_models(configuration, features) # All possible parameter values that could be taken on. This is some legacy # code from before there were many candidate models built. For very large # sets of candidate models, this could be quite slow. # TODO: we might be able to remove this initialization for clarity, depends on fixed poritions parameters = {} for candidate_models in candidate_models_features.values(): for model in candidate_models: for coef in model: parameters[coef] = 0 # These is our previously fit partial model from previous steps # Subtract out all of these contributions (zero out reference state because these are formation properties) fixed_model = Model(dbf, comps, phase_name, parameters={'GHSER'+(c.upper()*2)[:2]: 0 for c in comps}) fixed_portions = [0] for desired_props in fitting_steps: feature_type = desired_props[0].split('_')[0] # HM_FORM -> HM aicc_factor = aicc_feature_factors.get(feature_type, 1.0) solver_qry = (where('solver').test(symmetry_filter, configuration, recursive_tuplify(symmetry) if symmetry else symmetry)) desired_data = get_prop_data(comps, phase_name, desired_props, datasets, additional_query=solver_qry) desired_data = filter_configurations(desired_data, configuration, symmetry) desired_data = filter_temperatures(desired_data) _log.trace('%s: datasets found: %s', desired_props, len(desired_data)) if len(desired_data) > 0: config_tup = tuple(map(tuplify, configuration)) calculate_dict = get_prop_samples(desired_data, config_tup) sample_condition_dicts = _get_sample_condition_dicts(calculate_dict, list(map(len, config_tup))) weights = calculate_dict['weights'] assert len(sample_condition_dicts) == len(weights) # We assume all properties in the same fitting step have the same # features (all CPM, all HM, etc., but different ref states). # data quantities are the same for each candidate model and can be computed up front data_qtys = get_data_quantities(feature_type, fixed_model, fixed_portions, desired_data, sample_condition_dicts) # build the candidate model transformation matrix and response vector (A, b in Ax=b) feature_matricies = [] data_quantities = [] for candidate_coefficients in candidate_models_features[desired_props[0]]: # Map coeffiecients in G to coefficients in the feature_type (H, S, CP) transformed_coefficients = list(map(feature_transforms[feature_type], candidate_coefficients)) if interaction_test(configuration, 3): feature_matricies.append(_build_feature_matrix(sample_condition_dicts, transformed_coefficients)) else: feature_matricies.append(_build_feature_matrix(sample_condition_dicts, transformed_coefficients)) data_quantities.append(data_qtys) # provide candidate models and get back a selected model. selected_model = select_model(zip(candidate_models_features[desired_props[0]], feature_matricies, data_quantities), ridge_alpha, weights=weights, aicc_factor=aicc_factor) selected_features, selected_values = selected_model parameters.update(zip(*(selected_features, selected_values))) # Add these parameters to be fixed for the next fitting step fixed_portion = np.array(selected_features, dtype=np.object_) fixed_portion = np.dot(fixed_portion, selected_values) fixed_portions.append(fixed_portion) return parameters
def build_candidate_models(configuration, features): """ Return a dictionary of features and candidate models Parameters ---------- configuration : tuple Configuration tuple, e.g. (('A', 'B', 'C'), 'A') features : dict Dictionary of {str: list} of generic features for a model, not considering the configuration. For example: {'CPM_FORM': [sympy.S.One, v.T, v.T**2, v.T**3]} Returns ------- dict Dictionary of {feature: [candidate_models]) Notes ----- Currently only works for binary and ternary interactions. Candidate models match the following spec: 1. Candidates with multiple features specified will have 2. orders of parameters (L0, L0 and L1, ...) have the same number of temperatures Note that high orders of parameters with multiple temperatures are not required to contain all the temperatures of the low order parameters. For example, the following parameters can be generated L0: A L1: A + BT """ if not interaction_test(configuration): # endmembers only for feature in features.keys(): features[feature] = make_successive(features[feature]) return features elif interaction_test(configuration, 2): # has a binary interaction YS = sympy.Symbol( 'YS') # Product of all nonzero site fractions in all sublattices Z = sympy.Symbol('Z') # generate increasingly complex interactions, # L0, L1, L2 parameter_interactions = [YS, YS * Z, YS * (Z**2), YS * (Z**3)] for feature in features.keys(): candidate_feature_sets = build_feature_sets( features[feature], parameter_interactions) # list of (for example): ((['TlogT'], 'YS'), (['TlogT', 'T**2'], 'YS*Z')) candidate_models = [] for feat_set in candidate_feature_sets: # multiply the interactions through and flatten the feature list candidate_models.append( list( itertools.chain(*[[ param_order[1] * temp_feat for temp_feat in param_order[0] ] for param_order in feat_set]))) features[feature] = candidate_models return features elif interaction_test(configuration, 3): # has a ternary interaction # Ternaries interactions should have exactly two candidate models: # 1. a single symmetric ternary parameter (YS) # 2. L0, L1, and L2 parameters corresponding to Muggianu parameters # We are ignoring cases where we have L0 == L1, but L0 != L2 and all of the # combinations these cases don't exist in reality (where two elements have # exactly the same behavior) the symmetric case is mainly for small # corrections and dimensionality reduction. YS = sympy.Symbol( 'YS') # Product of all nonzero site fractions in all sublattices # Muggianu ternary interaction product for components i, j, and k V_I, V_J, V_K = sympy.Symbol('V_I'), sympy.Symbol('V_J'), sympy.Symbol( 'V_K') # because we don't want our parameter interactions to go sequentially, we'll construct models in two steps symmetric_interactions = [(YS, )] # symmetric L0 asymmetric_interactions = [(YS * V_I, YS * V_J, YS * V_K) ] # asymmetric L0, L1, and L2 for feature in features.keys(): sym_candidate_feature_sets = build_feature_sets( features[feature], symmetric_interactions) asym_candidate_feature_sets = build_feature_sets( features[feature], asymmetric_interactions) # list of (for example): ((['TlogT'], ('YS',)), (['TlogT', 'T**2'], ('YS',)) candidate_models = [] for feat_set in itertools.chain(sym_candidate_feature_sets, asym_candidate_feature_sets): feat_set_params = [] # multiply the interactions through with distributing for temp_feats, inter_feats in feat_set: # temperature features and interaction features feat_set_params.append([ inter * feat for inter, feat in itertools.product( temp_feats, inter_feats) ]) candidate_models.append(list( itertools.chain(*feat_set_params))) features[feature] = candidate_models return features
def fit_formation_energy(dbf, comps, phase_name, configuration, symmetry, datasets, ridge_alpha=1.0e-100, features=None): """ Find suitable linear model parameters for the given phase. We do this by successively fitting heat capacities, entropies and enthalpies of formation, and selecting against criteria to prevent overfitting. The "best" set of parameters minimizes the error without overfitting. Parameters ---------- dbf : Database pycalphad Database. Partially complete, so we know what degrees of freedom to fix. comps : [str] Names of the relevant components. phase_name : str Name of the desired phase for which the parameters will be found. configuration : ndarray Configuration of the sublattices for the fitting procedure. symmetry : [[int]] Symmetry of the sublattice configuration. datasets : PickleableTinyDB All the datasets desired to fit to. ridge_alpha : float Value of the $alpha$ hyperparameter used in ridge regression. Defaults to 1.0e-100, which should be degenerate with ordinary least squares regression. For now, the parameter is applied to all features. features : dict Maps "property" to a list of features for the linear model. These will be transformed from "GM" coefficients e.g., {"CPM_FORM": (v.T*sympy.log(v.T), v.T**2, v.T**-1, v.T**3)} (Default value = None) Returns ------- dict {feature: estimated_value} """ if interaction_test(configuration): logging.debug('ENDMEMBERS FROM INTERACTION: {}'.format( endmembers_from_interaction(configuration))) fitting_steps = (["CPM_FORM", "CPM_MIX"], ["SM_FORM", "SM_MIX"], ["HM_FORM", "HM_MIX"]) else: # We are only fitting an endmember; no mixing data needed fitting_steps = (["CPM_FORM"], ["SM_FORM"], ["HM_FORM"]) # create the candidate models and fitting steps if features is None: features = OrderedDict([("CPM_FORM", (v.T * sympy.log(v.T), v.T**2, v.T**-1, v.T**3)), ("SM_FORM", (v.T, )), ("HM_FORM", (sympy.S.One, ))]) # dict of {feature, [candidate_models]} candidate_models_features = build_candidate_models(configuration, features) # All possible parameter values that could be taken on. This is some legacy # code from before there were many candidate models built. For very large # sets of candidate models, this could be quite slow. # TODO: we might be able to remove this initialization for clarity, depends on fixed poritions parameters = {} for candidate_models in candidate_models_features.values(): for model in candidate_models: for coef in model: parameters[coef] = 0 # These is our previously fit partial model from previous steps # Subtract out all of these contributions (zero out reference state because these are formation properties) fixed_model = Model( dbf, comps, phase_name, parameters={'GHSER' + (c.upper() * 2)[:2]: 0 for c in comps}) fixed_model.models['idmix'] = 0 fixed_portions = [0] moles_per_formula_unit = sympy.S(0) YS = sympy.Symbol('YS') # site fraction symbol that we will reuse Z = sympy.Symbol('Z') # site fraction symbol that we will reuse subl_idx = 0 for num_sites, const in zip(dbf.phases[phase_name].sublattices, dbf.phases[phase_name].constituents): if v.Species('VA') in const: moles_per_formula_unit += num_sites * ( 1 - v.SiteFraction(phase_name, subl_idx, v.Species('VA'))) else: moles_per_formula_unit += num_sites subl_idx += 1 for desired_props in fitting_steps: desired_data = get_data(comps, phase_name, configuration, symmetry, datasets, desired_props) logging.debug('{}: datasets found: {}'.format(desired_props, len(desired_data))) if len(desired_data) > 0: # We assume all properties in the same fitting step have the same features (all CPM, all HM, etc.) (but different ref states) all_samples = get_samples(desired_data) site_fractions = [ build_sitefractions( phase_name, ds['solver']['sublattice_configurations'], ds['solver'].get( 'sublattice_occupancies', np.ones(( len(ds['solver']['sublattice_configurations']), len(ds['solver']['sublattice_configurations'][0])), dtype=np.float))) for ds in desired_data for _ in ds['conditions']['T'] ] # Flatten list site_fractions = list(itertools.chain(*site_fractions)) # build the candidate model transformation matrix and response vector (A, b in Ax=b) feature_matricies = [] data_quantities = [] for candidate_model in candidate_models_features[desired_props[0]]: if interaction_test(configuration, 3): feature_matricies.append( build_ternary_feature_matrix(desired_props[0], candidate_model, desired_data)) else: feature_matricies.append( _build_feature_matrix(desired_props[0], candidate_model, desired_data)) data_qtys = np.concatenate(shift_reference_state( desired_data, feature_transforms[desired_props[0]], fixed_model), axis=-1) # Remove existing partial model contributions from the data data_qtys = data_qtys - feature_transforms[desired_props[0]]( fixed_model.ast) # Subtract out high-order (in T) parameters we've already fit data_qtys = data_qtys - feature_transforms[desired_props[0]]( sum(fixed_portions)) / moles_per_formula_unit # if any site fractions show up in our data_qtys that aren't in this datasets site fractions, set them to zero. for sf, i, (_, (sf_product, inter_product)) in zip(site_fractions, data_qtys, all_samples): missing_variables = sympy.S( i * moles_per_formula_unit).atoms( v.SiteFraction) - set(sf.keys()) sf.update({x: 0. for x in missing_variables}) # The equations we have just have the site fractions as YS # and interaction products as Z, so take the product of all # the site fractions that we see in our data qtys sf.update({YS: sf_product, Z: inter_product}) # moles_per_formula_unit factor is here because our data is stored per-atom # but all of our fits are per-formula-unit data_qtys = [ sympy.S(i * moles_per_formula_unit).xreplace(sf).xreplace({ v.T: ixx[0] }).evalf() for i, sf, ixx in zip(data_qtys, site_fractions, all_samples) ] data_qtys = np.asarray(data_qtys, dtype=np.float) data_quantities.append(data_qtys) # provide candidate models and get back a selected model. selected_model = select_model( zip(candidate_models_features[desired_props[0]], feature_matricies, data_quantities), ridge_alpha) selected_features, selected_values = selected_model parameters.update(zip(*(selected_features, selected_values))) # Add these parameters to be fixed for the next fitting step fixed_portion = np.array(selected_features, dtype=np.object) fixed_portion = np.dot(fixed_portion, selected_values) fixed_portions.append(fixed_portion) return parameters
def build_candidate_models(configuration, features): """ Return a dictionary of features and candidate models Parameters ---------- configuration : tuple Configuration tuple, e.g. (('A', 'B', 'C'), 'A') features : dict Dictionary of {str: list} of generic features for a model, not considering the configuration. For example: {'CPM_FORM': [symengine.S.One, v.T, v.T**2, v.T**3]} Returns ------- dict Dictionary of {feature: [candidate_models]) Notes ----- Currently only works for binary and ternary interactions. Candidate models match the following spec: 1. Candidates with multiple features specified will have 2. orders of parameters (L0, L0 and L1, ...) have the same number of temperatures Note that high orders of parameters with multiple temperatures are not required to contain all the temperatures of the low order parameters. For example, the following parameters can be generated L0: A L1: A + BT """ feature_candidate_models = {} if not interaction_test(configuration): # endmembers only for feature_name, temperature_features in features.items(): interaction_features = (symengine.S.One, ) feature_candidate_models[feature_name] = build_feature_sets( temperature_features, interaction_features) elif interaction_test(configuration, 2): # has a binary interaction YS = symengine.Symbol( 'YS') # Product of all nonzero site fractions in all sublattices Z = symengine.Symbol('Z') for feature_name, temperature_features in features.items(): # generate increasingly complex interactions (power of Z is Redlich-Kister order) interaction_features = (YS, YS * Z, YS * (Z**2), YS * (Z**3) ) # L0, L1, L2, L3 feature_candidate_models[feature_name] = build_feature_sets( temperature_features, interaction_features) elif interaction_test(configuration, 3): # has a ternary interaction # Ternaries interactions should have exactly two interaction sets: # 1. a single symmetric ternary parameter (YS) YS = symengine.Symbol( 'YS') # Product of all nonzero site fractions in all sublattices # 2. L0, L1, and L2 parameters V_I, V_J, V_K = symengine.Symbol('V_I'), symengine.Symbol( 'V_J'), symengine.Symbol('V_K') symmetric_interactions = (YS, ) # symmetric L0 for feature_name, temperature_features in features.items(): # We are ignoring cases where we have L0 == L1 != L2 (and like # permutations) because these cases (where two elements exactly the # same behavior) don't exist in reality. Tthe symmetric case is # mainly for small corrections and dimensionality reduction. # Because we don't want our parameter interactions to be successive # (i.e. products of symmetric and asymmetric terms), we'll candidates in two steps tern_ix_cands = [] tern_ix_cands += build_feature_sets(temperature_features, symmetric_interactions) # special handling for asymmetric features, we don't want a successive V_I, V_J, V_K, but all three should be present asym_feats = ( build_feature_sets(temperature_features, (YS * V_I, )), # asymmetric L0 build_feature_sets(temperature_features, (YS * V_J, )), # asymmetric L1 build_feature_sets(temperature_features, (YS * V_K, )), # asymmetric L2 ) for v_i_feats, v_j_feats, v_k_feats in zip(*asym_feats): tern_ix_cands.append(v_i_feats + v_j_feats + v_k_feats) feature_candidate_models[feature_name] = tern_ix_cands else: raise ValueError( f"Interaction order not known for configuration {configuration}") return feature_candidate_models