"""Classes concerned with solving for decoders or full weight matrices.
.. inheritance-diagram:: nengo.solvers
:parts: 1
:top-classes: nengo.solvers.Solver, nengo.solvers.SolverParam
"""
import time
import numpy as np
import nengo.utils.least_squares_solvers as lstsq
from nengo.params import BoolParam, FrozenObject, NdarrayParam, NumberParam, Parameter
from nengo.utils.least_squares_solvers import (
format_system,
rmses,
LeastSquaresSolverParam,
)
[docs]class Solver(FrozenObject):
"""Decoder or weight solver.
A solver can have the ``weights`` parameter equal to ``True`` or ``False``.
Weight solvers are used to form neuron-to-neuron weight matrices.
They can be compositional or non-compositional. Non-compositional
solvers must operate on the whole neuron-to-neuron weight matrix
(i.e., each target is a separate postsynaptic current, without the bias
term), while compositional solvers operate in the decoded state-space
(i.e., each target is a dimension in state-space). Compositional solvers
then combine the returned ``X`` with the transform and/or encoders to
generate the full weight matrix.
For a solver to be compositional, the following property must be true::
X = solver(A, Y) if and only if L(X) = solver(A, L(Y))
where ``L`` is some arbitrary linear operator (i.e., the transform and/or
encoders for the postsynaptic population). This property can then be
leveraged by the backend for efficiency. See the solver's
``compositional`` class attribute to determine if it is compositional.
Non-weight solvers always operate in the decoded state-space regardless of
whether they are compositional or non-compositional.
"""
compositional = True
weights = BoolParam("weights")
def __init__(self, weights=False):
super().__init__()
self.weights = weights
def __call__(self, A, Y, rng=np.random):
"""Call the solver.
.. note:: ``n_targets`` is ``dimensions`` if ``solver.weights`` is ``False``
and ``post.n_neurons`` if ``solver.weights`` is ``True``.
Parameters
----------
A : (n_eval_points, n_neurons) array_like
Matrix of the neurons' activities at the evaluation points.
Y : (n_eval_points, n_targets) array_like
Matrix of target values at the evaluation points.
rng : `numpy.random.mtrand.RandomState`, optional
A random number generator to use as required.
Returns
-------
X : (n_neurons, n_targets) array_like
Matrix of weights used to map activities onto targets.
A typical solver will approximate ``dot(A, X) ~= Y`` subject to
some constraints on ``X``.
info : dict
A dictionary of information about the solver. All dictionaries have
an ``'rmses'`` key that contains RMS errors of the solve (one per
target). Other keys are unique to particular solvers.
"""
raise NotImplementedError("Solvers must implement '__call__'")
[docs]class SolverParam(Parameter):
"""A parameter in which the value is a `.Solver` instance."""
def coerce(self, instance, solver):
self.check_type(instance, solver, Solver)
return super().coerce(instance, solver)
[docs]class Lstsq(Solver):
"""Unregularized least-squares solver.
Parameters
----------
weights : bool, optional
If False, solve for decoders. If True, solve for weights.
rcond : float, optional
Cut-off ratio for small singular values (see `numpy.linalg.lstsq`).
Attributes
----------
rcond : float
Cut-off ratio for small singular values (see `numpy.linalg.lstsq`).
weights : bool
If False, solve for decoders. If True, solve for weights.
"""
rcond = NumberParam("noise", low=0)
def __init__(self, weights=False, rcond=0.01):
super().__init__(weights=weights)
self.rcond = rcond
def __call__(self, A, Y, rng=np.random):
tstart = time.time()
X, residuals2, rank, s = np.linalg.lstsq(A, Y, rcond=self.rcond)
t = time.time() - tstart
return (
X,
{
"rmses": rmses(A, X, Y),
"residuals": np.sqrt(residuals2),
"rank": rank,
"singular_values": s,
"time": t,
},
)
def _add_noise_param_docs(cls):
cls.__doc__ += """
Parameters
----------
weights : bool, optional
If False, solve for decoders. If True, solve for weights.
noise : float, optional
Amount of noise, as a fraction of the neuron activity.
solver : `.LeastSquaresSolver`, optional
Subsolver to use for solving the least squares problem.
Attributes
----------
noise : float
Amount of noise, as a fraction of the neuron activity.
solver : `.LeastSquaresSolver`
Subsolver to use for solving the least squares problem.
weights : bool
If False, solve for decoders. If True, solve for weights.
"""
return cls
[docs]@_add_noise_param_docs
class LstsqNoise(Solver):
"""Least-squares solver with additive Gaussian white noise."""
noise = NumberParam("noise", low=0)
solver = LeastSquaresSolverParam("solver")
def __init__(self, weights=False, noise=0.1, solver=lstsq.Cholesky()):
super().__init__(weights=weights)
self.noise = noise
self.solver = solver
def __call__(self, A, Y, rng=np.random):
tstart = time.time()
sigma = self.noise * A.max()
A = A + rng.normal(scale=sigma, size=A.shape)
X, info = self.solver(A, Y, 0, rng=rng)
info["time"] = time.time() - tstart
return X, info
[docs]@_add_noise_param_docs
class LstsqMultNoise(LstsqNoise):
"""Least-squares solver with multiplicative white noise."""
def __call__(self, A, Y, rng=np.random):
tstart = time.time()
A = A + rng.normal(scale=self.noise, size=A.shape) * A
X, info = self.solver(A, Y, 0, rng=rng)
info["time"] = time.time() - tstart
return X, info
def _add_l2_param_docs(cls):
cls.__doc__ += """
Parameters
----------
weights : bool, optional
If False, solve for decoders. If True, solve for weights.
reg : float, optional
Amount of regularization, as a fraction of the neuron activity.
solver : `.LeastSquaresSolver`, optional
Subsolver to use for solving the least squares problem.
Attributes
----------
reg : float
Amount of regularization, as a fraction of the neuron activity.
solver : `.LeastSquaresSolver`
Subsolver to use for solving the least squares problem.
weights : bool
If False, solve for decoders. If True, solve for weights.
"""
return cls
[docs]@_add_l2_param_docs
class LstsqL2(Solver):
"""Least-squares solver with L2 regularization."""
reg = NumberParam("reg", low=0)
solver = LeastSquaresSolverParam("solver")
def __init__(self, weights=False, reg=0.1, solver=lstsq.Cholesky()):
super().__init__(weights=weights)
self.reg = reg
self.solver = solver
def __call__(self, A, Y, rng=np.random):
tstart = time.time()
sigma = self.reg * A.max()
X, info = self.solver(A, Y, sigma, rng=rng)
info["time"] = time.time() - tstart
return X, info
[docs]@_add_l2_param_docs
class LstsqL2nz(LstsqL2):
"""Least-squares solver with L2 regularization on non-zero components."""
def __call__(self, A, Y, rng=np.random):
tstart = time.time()
# Compute the equivalent noise standard deviation. This equals the
# base amplitude (noise_amp times the overall max activation) times
# the square-root of the fraction of non-zero components.
sigma = (self.reg * A.max()) * np.sqrt((A > 0).mean(axis=0))
# sigma == 0 means the neuron is never active, so won't be used, but
# we have to make sigma != 0 for numeric reasons.
sigma[sigma == 0] = sigma.max()
X, info = self.solver(A, Y, sigma, rng=rng)
info["time"] = time.time() - tstart
return X, info
[docs]class LstsqL1(Solver):
"""Least-squares solver with L1 and L2 regularization (elastic net).
This method is well suited for creating sparse decoders or weight matrices.
.. note:: Requires `scikit-learn <https://scikit-learn.org/stable/>`_.
Parameters
----------
weights : bool, optional
If False, solve for decoders. If True, solve for weights.
l1 : float, optional
Amount of L1 regularization.
l2 : float, optional
Amount of L2 regularization.
max_iter : int, optional
Maximum number of iterations for the underlying elastic net.
Attributes
----------
l1 : float
Amount of L1 regularization.
l2 : float
Amount of L2 regularization.
weights : bool
If False, solve for decoders. If True, solve for weights.
max_iter : int
Maximum number of iterations for the underlying elastic net.
"""
compositional = False
l1 = NumberParam("l1", low=0)
l2 = NumberParam("l2", low=0)
def __init__(self, weights=False, l1=1e-4, l2=1e-6, max_iter=1000):
# import to check existence
import sklearn.linear_model # pylint: disable=import-outside-toplevel
assert sklearn.linear_model
super().__init__(weights=weights)
self.l1 = l1
self.l2 = l2
self.max_iter = max_iter
def __call__(self, A, Y, rng=np.random):
import sklearn.linear_model # pylint: disable=import-outside-toplevel
tstart = time.time()
Y = np.array(Y) # copy since 'fit' may modify Y
# TODO: play around with regularization constants (I just guessed).
# Do we need to scale regularization by number of neurons, to get
# same level of sparsity? esp. with weights? Currently, setting
# l1=1e-3 works well with weights when connecting 1D populations
# with 100 neurons each.
a = self.l1 * A.max() # L1 regularization
b = self.l2 * A.max() ** 2 # L2 regularization
alpha = a + b
l1_ratio = a / (a + b)
# --- solve least-squares A * X = Y
model = sklearn.linear_model.ElasticNet(
alpha=alpha, l1_ratio=l1_ratio, fit_intercept=False, max_iter=self.max_iter
)
model.fit(A, Y)
X = model.coef_.T
X.shape = (A.shape[1], Y.shape[1]) if Y.ndim > 1 else (A.shape[1],)
t = time.time() - tstart
infos = {"rmses": rmses(A, X, Y), "time": t}
return X, infos
[docs]class LstsqDrop(Solver):
"""Find sparser decoders/weights by dropping small values.
This solver first solves for coefficients (decoders/weights) with
L2 regularization, drops those nearest to zero, and retrains remaining.
Parameters
----------
weights : bool, optional
If False, solve for decoders. If True, solve for weights.
drop : float, optional
Fraction of decoders or weights to set to zero.
solver1 : Solver, optional
Solver for finding the initial decoders.
solver2 : Solver, optional
Used for re-solving for the decoders after dropout.
Attributes
----------
drop : float
Fraction of decoders or weights to set to zero.
solver1 : Solver
Solver for finding the initial decoders.
solver2 : Solver
Used for re-solving for the decoders after dropout.
weights : bool
If False, solve for decoders. If True, solve for weights.
"""
compositional = False
drop = NumberParam("drop", low=0, high=1)
solver1 = SolverParam("solver1")
solver2 = SolverParam("solver2")
def __init__(
self,
weights=False,
drop=0.25,
solver1=LstsqL2(reg=0.001),
solver2=LstsqL2(reg=0.1),
):
super().__init__(weights=weights)
self.drop = drop
self.solver1 = solver1
self.solver2 = solver2
def __call__(self, A, Y, rng=np.random):
tstart = time.time()
Y, m, n, _, matrix_in = format_system(A, Y)
# solve for coefficients using standard solver
X, info0 = self.solver1(A, Y, rng=rng)
# drop weights close to zero, based on `drop` ratio
Xabs = np.sort(np.abs(X.flat))
threshold = Xabs[int(np.round(self.drop * Xabs.size))]
X[np.abs(X) < threshold] = 0
# retrain nonzero weights
for i in range(X.shape[1]):
nonzero = X[:, i] != 0
if nonzero.sum() > 0:
X[nonzero, i], info1 = self.solver2(A[:, nonzero], Y[:, i], rng=rng)
t = time.time() - tstart
info = {"rmses": rmses(A, X, Y), "info0": info0, "info1": info1, "time": t}
return X if matrix_in or X.shape[1] > 1 else X.ravel(), info
def _add_nnls_param_docs(l2=False):
reg_attr = """
reg : float
Amount of regularization, as a fraction of the neuron activity.\
"""
reg_param = """
reg : float, optional
Amount of regularization, as a fraction of the neuron activity.\
"""
docstring = """
.. note:: Requires
`SciPy <https://docs.scipy.org/doc/scipy/reference/>`_.
Parameters
----------
weights : bool, optional
If False, solve for decoders. If True, solve for weights.{reg_param}
Attributes
----------{reg_attr}
weights : bool
If False, solve for decoders. If True, solve for weights.
""".format(
reg_param=reg_param if l2 else "", reg_attr=reg_attr if l2 else ""
)
def _actually_add_nnls_param_docs(cls):
cls.__doc__ += docstring
return cls
return _actually_add_nnls_param_docs
[docs]@_add_nnls_param_docs(l2=False)
class Nnls(Solver):
"""Non-negative least-squares solver without regularization.
Similar to `.Lstsq`, except the output values are non-negative.
If solving for non-negative **weights**, it is important that the
intercepts of the post-population are also non-negative, since neurons with
negative intercepts will never be silent, affecting output accuracy.
"""
compositional = False
def __init__(self, weights=False):
# import here too to throw error early
import scipy.optimize # pylint: disable=import-outside-toplevel
assert scipy.optimize
super().__init__(weights=weights)
def __call__(self, A, Y, rng=np.random):
import scipy.optimize # pylint: disable=import-outside-toplevel
tstart = time.time()
Y, m, n, _, matrix_in = format_system(A, Y)
d = Y.shape[1]
X = np.zeros((n, d))
residuals = np.zeros(d)
for i in range(d):
X[:, i], residuals[i] = scipy.optimize.nnls(A, Y[:, i])
t = time.time() - tstart
info = {"rmses": rmses(A, X, Y), "residuals": residuals, "time": t}
return X if matrix_in or X.shape[1] > 1 else X.ravel(), info
[docs]@_add_nnls_param_docs(l2=True)
class NnlsL2(Nnls):
"""Non-negative least-squares solver with L2 regularization.
Similar to `.LstsqL2`, except the output values are non-negative.
If solving for non-negative **weights**, it is important that the
intercepts of the post-population are also non-negative, since neurons with
negative intercepts will never be silent, affecting output accuracy.
"""
reg = NumberParam("reg", low=0)
def __init__(self, weights=False, reg=0.1):
super().__init__(weights=weights)
self.reg = reg
def _solve(self, A, Y, sigma=0.0):
import scipy.optimize # pylint: disable=import-outside-toplevel
tstart = time.time()
Y, m, n, _, matrix_in = format_system(A, Y)
d = Y.shape[1]
# form Gram matrix so we can add regularization
GA = np.dot(A.T, A)
np.fill_diagonal(GA, GA.diagonal() + A.shape[0] * sigma ** 2)
GY = np.dot(A.T, Y.clip(0, None))
# ^ TODO: why is it better if we clip Y to be positive here?
X = np.zeros((n, d))
residuals = np.zeros(d)
for i in range(d):
X[:, i], residuals[i] = scipy.optimize.nnls(GA, GY[:, i])
t = time.time() - tstart
info = {"rmses": rmses(A, X, Y), "residuals": residuals, "time": t}
return X if matrix_in or X.shape[1] > 1 else X.ravel(), info
def __call__(self, A, Y, rng=np.random):
return self._solve(A, Y, sigma=self.reg * A.max())
[docs]@_add_nnls_param_docs(l2=True)
class NnlsL2nz(NnlsL2):
"""Non-negative least-squares with L2 regularization on nonzero components.
Similar to `.LstsqL2nz`, except the output values are non-negative.
If solving for non-negative **weights**, it is important that the
intercepts of the post-population are also non-negative, since neurons with
negative intercepts will never be silent, affecting output accuracy.
"""
def __call__(self, A, Y, rng=np.random):
sigma = (self.reg * A.max()) * np.sqrt((A > 0).mean(axis=0))
sigma[sigma == 0] = 1
return self._solve(A, Y, sigma=sigma)
[docs]class NoSolver(Solver):
"""Manually pass in weights, bypassing the decoder solver.
Parameters
----------
values : (n_neurons, size_out) array_like, optional
The array of decoders to use.
``size_out`` is the dimensionality of the decoded signal (determined
by the connection function).
If ``None``, which is the default, the solver will return an
appropriately sized array of zeros.
weights : bool, optional
If False, connection will use factored weights (decoders from this
solver, transform, and encoders).
If True, connection will use a full weight matrix (created by
linearly combining decoder, transform, and encoders).
Attributes
----------
values : (n_neurons, size_out) array_like, optional
The array of decoders to use.
``size_out`` is the dimensionality of the decoded signal (determined
by the connection function).
If ``None``, which is the default, the solver will return an
appropriately sized array of zeros.
weights : bool, optional
If False, connection will use factored weights (decoders from this
solver, transform, and encoders).
If True, connection will use a full weight matrix (created by
linearly combining decoder, transform, and encoders).
"""
compositional = True
values = NdarrayParam("values", optional=True, shape=("*", "*"))
def __init__(self, values=None, weights=False):
super().__init__(weights=weights)
self.values = values
def __call__(self, A, Y, rng=None):
if self.values is None:
n_neurons = np.asarray(A).shape[1]
return np.zeros((n_neurons, np.asarray(Y).shape[1])), {}
return self.values, {}