Skip to content

Commit 3386bda

Browse files
authored
Merge pull request #65 from heal-research/feat/sample-weight
feat(sklearn): add sample_weight support to SymbolicRegressor.fit()
2 parents 67798b4 + 22209b3 commit 3386bda

10 files changed

Lines changed: 1098 additions & 6 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ reg.fit(X, y)
2525
```
2626
Subclass `pyoperon.Callback` (`on_fit_begin`/`on_generation_end`/`on_fit_end`) for custom monitoring; return `True` from `on_generation_end` to request early termination. See `pyoperon/callback.py` for the full API.
2727

28+
`fit()` also accepts `sample_weight=`, matching the scikit-learn convention, for training on non-uniformly sampled data (e.g. stratified/reweighted survey data, or down-weighting outliers) without distorting the fitted model:
29+
```python
30+
reg.fit(X, y, sample_weight=sample_weight)
31+
```
32+
2833
The [example](https://github.com/heal-research/pyoperon/tree/main/example) folder contains sample code for using either the Python bindings directly or the **pyoperon.sklearn** module.
2934

3035
# Installation

flake.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyoperon/sklearn.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -779,7 +779,7 @@ def get_model_string(
779779
names_map = {k: names[i] for i, k in enumerate(self.variables_)}
780780
return op.InfixFormatter.Format(model, names_map, precision)
781781

782-
def fit(self, X, y):
782+
def fit(self, X, y, sample_weight=None):
783783
"""Fit the symbolic regression model.
784784
785785
Parameters
@@ -788,6 +788,22 @@ def fit(self, X, y):
788788
Training input samples.
789789
y : array-like of shape (n_samples,)
790790
Target values.
791+
sample_weight : array-like of shape (n_samples,), default=None
792+
Per-sample weights applied to the fitness/selection loss during
793+
evolution, to coefficient optimization when `optimizer_iterations
794+
> 0` and `optimizer_likelihood='gaussian'` (the default), and to
795+
the post-hoc scale/intercept refit and the reported
796+
`mean_squared_error` stat. `None` weights every sample equally.
797+
Must be a proper 1D array of length `n_samples` - unlike some
798+
scikit-learn estimators, a scalar or an (n_samples, 1) column
799+
vector is not broadcast.
800+
801+
Poisson likelihoods (`optimizer_likelihood='poisson'` or
802+
`'poisson_log'`) do not yet support sample_weight in coefficient
803+
optimization: `w` there is already used for the exposure/offset
804+
term, a different semantic from a precision weight, and
805+
reconciling the two is unresolved. A warning is raised in that
806+
case when `optimizer_iterations > 0`.
791807
792808
Returns
793809
-------
@@ -814,10 +830,45 @@ def fit(self, X, y):
814830

815831
X, y = check_X_y(X, y, accept_sparse=False)
816832
self.n_features_in_ = X.shape[1]
833+
if sample_weight is not None:
834+
# Public-API equivalent of sklearn's private _check_sample_weight:
835+
# avoid depending on underscore-prefixed internals given
836+
# pyproject's loose `scikit-learn>=1.6.1` pin. dtype=None just
837+
# avoids needlessly forcing float32 - the nanobind SetWeights/
838+
# FitLeastSquares bindings convert to whatever Operon::Scalar
839+
# actually is regardless of the precision passed in here.
840+
# Unlike _check_sample_weight, this does not broadcast a scalar
841+
# or accept an (n_samples, 1) column vector - a real 1D array
842+
# matching X's length is required, by choice, not omission.
843+
sample_weight = check_array(
844+
sample_weight, ensure_2d=False, dtype=None,
845+
input_name='sample_weight',
846+
)
847+
if sample_weight.ndim != 1 or sample_weight.shape[0] != X.shape[0]:
848+
raise ValueError(
849+
f'sample_weight has shape {sample_weight.shape}, '
850+
f'expected ({X.shape[0]},)'
851+
)
852+
if np.any(sample_weight < 0):
853+
raise ValueError('sample_weight must be non-negative')
854+
if not np.any(sample_weight > 0):
855+
raise ValueError('sample_weight must not be all zero')
856+
if optimizer_iterations > 0 and self.optimizer_likelihood in ('poisson', 'poisson_log'):
857+
warnings.warn(
858+
'sample_weight is set but optimizer_iterations > 0 with '
859+
f'optimizer_likelihood={self.optimizer_likelihood!r}: '
860+
'coefficient optimization does not yet support '
861+
'sample_weight for Poisson likelihoods, so coefficients '
862+
'may be tuned against a different objective than the '
863+
'one used for selection.',
864+
stacklevel=2,
865+
)
817866

818867
# Build dataset and problem
819868
D = np.asfortranarray(np.column_stack((X, y)))
820869
ds = op.Dataset(D)
870+
if sample_weight is not None:
871+
ds.SetWeights(sample_weight)
821872
target = max(ds.Variables, key=lambda x: x.Index)
822873
self.variables_ = {
823874
v.Hash: v.Name
@@ -970,7 +1021,10 @@ def report() -> bool:
9701021

9711022
def get_solution_stats(solution):
9721023
y_pred = op.Evaluate(dtable, solution.Genotype, ds, training_range)
973-
scale, offset = op.FitLeastSquares(y_pred, y)
1024+
if sample_weight is None:
1025+
scale, offset = op.FitLeastSquares(y_pred, y)
1026+
else:
1027+
scale, offset = op.FitLeastSquares(y_pred, y, sample_weight)
9741028
nodes = solution.Genotype.Nodes
9751029

9761030
if not add_scale:
@@ -996,7 +1050,7 @@ def get_solution_stats(solution):
9961050
'tree': solution.Genotype,
9971051
'objective_values': evaluator(rng, solution),
9981052
'mean_squared_error': mean_squared_error(
999-
y, scale * y_pred + offset,
1053+
y, scale * y_pred + offset, sample_weight=sample_weight,
10001054
),
10011055
'minimum_description_length': mdl_eval(rng, solution)[0],
10021056
'bayesian_information_criterion': bic_eval(rng, solution)[0],

source/dataset.cpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ void InitDataset(nb::module_ &m)
104104
.def("GetVariable", nb::overload_cast<const std::string&>(&Operon::Dataset::GetVariable, nb::const_))
105105
.def("GetVariable", nb::overload_cast<Operon::Hash>(&Operon::Dataset::GetVariable, nb::const_))
106106
.def_prop_ro("Variables", &Operon::Dataset::GetVariables)
107+
.def("SetWeights", [](Operon::Dataset& self, std::vector<Operon::Scalar> const& w) { self.SetWeights(w); })
108+
// Like GetValues/Values above, this is a view into weights_; a
109+
// subsequent SetWeights() reallocates the backing vector, so
110+
// re-fetch Weights after calling SetWeights rather than holding
111+
// onto a stale view.
112+
.def_prop_ro("Weights", [](Operon::Dataset const& self) -> nb::object {
113+
auto w = self.Weights();
114+
return w ? nb::cast(MakeView(*w, nb::find(self))) : nb::none();
115+
})
107116
.def("Shuffle", &Operon::Dataset::Shuffle)
108117
.def("Normalize", &Operon::Dataset::Normalize)
109118
.def("Standardize", &Operon::Dataset::Standardize)

source/evaluator.cpp

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ auto FitLeastSquares(nb::ndarray<T> lhs, nb::ndarray<T> rhs) -> std::pair<double
2323
return Operon::FitLeastSquares(s1, s2);
2424
}
2525

26+
template<typename T>
27+
auto FitLeastSquares(nb::ndarray<T> lhs, nb::ndarray<T> rhs, nb::ndarray<T> weights) -> std::pair<double, double>
28+
{
29+
auto s1 = MakeSpan(lhs);
30+
auto s2 = MakeSpan(rhs);
31+
auto s3 = MakeSpan(weights);
32+
return Operon::FitLeastSquares(s1, s2, s3);
33+
}
34+
2635
template<typename T>
2736
auto PoissonLikelihood(nb::ndarray<T> x, nb::ndarray<T> y) {
2837
return TPoissonLikelihood::ComputeLikelihood(MakeSpan(x), MakeSpan(y), {});
@@ -169,6 +178,14 @@ void InitEval(nb::module_ &m)
169178
return detail::FitLeastSquares<double>(lhs, rhs);
170179
});
171180

181+
m.def("FitLeastSquares", [](nb::ndarray<float> lhs, nb::ndarray<float> rhs, nb::ndarray<float> weights) -> std::pair<double, double> {
182+
return detail::FitLeastSquares<float>(lhs, rhs, weights);
183+
});
184+
185+
m.def("FitLeastSquares", [](nb::ndarray<double> lhs, nb::ndarray<double> rhs, nb::ndarray<double> weights) -> std::pair<double, double> {
186+
return detail::FitLeastSquares<double>(lhs, rhs, weights);
187+
});
188+
172189
// dispatch table
173190
nb::class_<TDispatch>(m, "DispatchTable")
174191
.def(nb::init<>());

0 commit comments

Comments
 (0)