# Source code for icet.tools.constraints

import numpy as np
from scipy.linalg import null_space
import itertools

[docs]class Constraints:
""" Class for handling linear constraints with right hand side equal to zero.

Parameters
----------
n_params
number of parameters in model

Example
-------
The following example demonstrates fitting of a cluster expansion under the
constraint that parameter 2 and parameter 4 should be equal::

>>> from icet.tools import Constraints
>>> from icet.fitting import Optimizer
>>> import numpy as np

>>> # Set up random sensing matrix and target "energies"
>>> n_params = 10
>>> A = np.random.random((10, n_params))
>>> y = np.random.random(10)

>>> # Define constraints
>>> c = Constraints(n_params=n_params)
>>> M = np.zeros((1, n_params))
>>> M[0, [2, 4]] = 1

>>> # Do the actual fit and finally extract parameters
>>> A_constrained = c.transform(A)
>>> opt = Optimizer((A_constrained, y), fit_method='ridge')
>>> opt.train()
>>> parameters = c.inverse_transform(opt.parameters)

"""

def __init__(self, n_params: int):
self.M = np.empty((0, n_params))
self.constraint_vectors = np.eye(n_params)

[docs]    def transform(self, A: np.ndarray) -> np.ndarray:
""" Transform array to constrained parameter space

Parameters
----------
A
array to be transformed
"""
return A.dot(self.constraint_vectors)

[docs]    def inverse_transform(self, A: np.ndarray) -> np.ndarray:
""" Inverse transform array from constrained parameter space
to unconstrained space

Parameters
----------
A
array to be inversed transformed
"""
return self.constraint_vectors.dot(A)

[docs]    def add_constraint(self, M: np.ndarray) -> None:
""" Add a constraint matrix and resolve for the constraint space

Parameters
----------
M
Constraint matrix with each constraint as a row. Can (but need not be)
cluster vectors.
"""
M = np.array(M)
self.M = np.vstack((self.M, M))
self.constraint_vectors = null_space(self.M)

[docs]def get_mixing_energy_constraints(cluster_space) -> Constraints:
"""
A cluster expansion of *mixing energy* should ideally predict zero energy
for concentration 0 and 1. This function constructs a :class:Constraints
object that enforces that condition during fitting.

Parameters
----------
cluster_space : ClusterSpace
Cluster space corresponding to cluster expansion for which constraints
should be imposed

Example
-------
This example demonstrates how to constrain the mixing energy to zero
at the pure phases in a toy example with random cluster vectors and
random target energies::

>>> from icet.tools import get_mixing_energy_constraints
>>> from icet.fitting import Optimizer
>>> from icet import ClusterSpace
>>> from ase.build import bulk
>>> import numpy as np

>>> # Set up cluster space along with random sensing matrix and target "energies"
>>> prim = bulk('Au')
>>> cs = ClusterSpace(prim, cutoffs=[6.0, 5.0], chemical_symbols=['Au', 'Ag'])
>>> n_params = len(cs)
>>> A = np.random.random((10, len(cs)))
>>> y = np.random.random(10)

>>> # Define constraints
>>> c = get_mixing_energy_constraints(cs)

>>> # Do the actual fit and finally extract parameters
>>> A_constrained = c.transform(A)
>>> opt = Optimizer((A_constrained, y), fit_method='ridge')
>>> opt.train()
>>> parameters = c.inverse_transform(opt.parameters)

Warning
-------
Constraining the energy of one structure is always done at the expense of the
fit quality of the others. Always expect that your :term:CV scores will increase
somewhat when using this function.
"""
M = []

prim = cluster_space.primitive_structure.copy()
sublattices = cluster_space.get_sublattices(prim)
chemical_symbols = [subl.chemical_symbols for subl in sublattices]

# Loop over all combinations of pure phases
for symbols in itertools.product(*chemical_symbols):
structure = prim.copy()
for subl, symbol in zip(sublattices, symbols):
for atom_index in subl.indices:
structure[atom_index].symbol = symbol

# Add constraint for this pure phase
M.append(cluster_space.get_cluster_vector(structure))

c = Constraints(n_params=len(cluster_space))