Пример #1
0
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]
Пример #2
0
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
Пример #4
0
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
Пример #5
0
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
Пример #6
0
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
Пример #7
0
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