Skip to content

Add preprocessing.GapEncoder - #3

Open
Fazel94 wants to merge 21 commits into
mainfrom
gap-encoder
Open

Add preprocessing.GapEncoder#3
Fazel94 wants to merge 21 commits into
mainfrom
gap-encoder

Conversation

@Fazel94

@Fazel94 Fazel94 commented Jul 2, 2026

Copy link
Copy Markdown
Owner

Online GapEncoder for online-ml#1439 (streaming Gamma-Poisson over character n-grams). Opening this against my own fork's main first, just to run CI before sending it upstream.

mugulmd and others added 20 commits July 7, 2026 21:32
A `metrics.base.Metrics` collection (built via `metric_a + metric_b`)
accepts a sample weight `w` in `update`, but forwarded to its child
metrics without it, so weights were silently treated as 1. The sibling
`revert` already passed `w`, which made the two asymmetric: a weighted
metric inside a collection reported the wrong value, and an
`update(w)` / `revert(w)` pair no longer cancelled (it could raise
"Cannot go below 0" from the underlying running statistic).

Forward `w` in the three `update` call sites, mirroring `revert`.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bumps [mistune](https://github.com/lepture/mistune) from 3.2.1 to 3.3.0.
- [Release notes](https://github.com/lepture/mistune/releases)
- [Changelog](https://github.com/lepture/mistune/blob/main/docs/changes.rst)
- [Commits](lepture/mistune@v3.2.1...v3.3.0)

---
updated-dependencies:
- dependency-name: mistune
  dependency-version: 3.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [soupsieve](https://github.com/facelessuser/soupsieve) from 2.8.3 to 2.8.4.
- [Release notes](https://github.com/facelessuser/soupsieve/releases)
- [Commits](facelessuser/soupsieve@2.8.3...2.8.4)

---
updated-dependencies:
- dependency-name: soupsieve
  dependency-version: 2.8.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…1940)

- Updated `Cargo.lock` to include new dependencies: `anyhow`, `approx`, `bitflags`, `cc`, `cfg_aliases`, `colored`, `find-msvc-tools`, `getrandom`, `glob`, `nix`, `r-efi`, and `statrs`.
- Replaced `criterion` with `codspeed-criterion-compat` in `Cargo.toml` for benchmarking.
- Added CodSpeed benchmarking setup in GitHub Actions with workflows for both Python and Rust benchmarks.
- Introduced new benchmark tests for linear models and statistics in `benchmarks/codspeed`.
- Updated documentation to include instructions for running benchmarks locally and integrating with CodSpeed.
* Update dependencies and add CodSpeed benchmarking support

- Updated `Cargo.lock` to include new dependencies: `anyhow`, `approx`, `bitflags`, `cc`, `cfg_aliases`, `colored`, `find-msvc-tools`, `getrandom`, `glob`, `nix`, `r-efi`, and `statrs`.
- Replaced `criterion` with `codspeed-criterion-compat` in `Cargo.toml` for benchmarking.
- Added CodSpeed benchmarking setup in GitHub Actions with workflows for both Python and Rust benchmarks.
- Introduced new benchmark tests for linear models and statistics in `benchmarks/codspeed`.
- Updated documentation to include instructions for running benchmarks locally and integrating with CodSpeed.

* Add benchmarks for various statistical and machine learning functions

- Introduced new benchmark files for `adwin_bench`, `rolling_metrics_bench`, `sorted_window_bench`, `covariance_bench`, and `expected_mutual_info_bench` in the Rust benchmarks directory.
- Updated `Cargo.toml` to include new benchmark configurations.
- Added Python benchmark tests for various algorithms in the CodSpeed benchmarking framework, including tests for active learning, anomaly detection, and ensemble methods.
- Configured per-file ignores for specific benchmarks in `pyproject.toml` to streamline linting.
- Enhanced the `workloads.py` file to support new benchmark scenarios with deterministic data streams.

* Update benchmarks and documentation for Python and Rust

- Added new benchmark tests for various algorithms in the CodSpeed framework, including active learning, anomaly detection, and ensemble methods.
- Introduced Rust benchmarks for adaptive windowing, covariance, expected mutual information, and rolling metrics.
- Updated `Cargo.toml` to include new benchmark paths and configurations.
- Revised `CONTRIBUTING.md` to reflect changes in benchmark directory structure.
- Adjusted `pyproject.toml` to ignore specific benchmark files for linting.
- Enhanced `benchmarks/README.md` with instructions for running benchmarks locally and integrating with CodSpeed.
- Added new Python workload functions to support deterministic data streams for benchmarks.

* Enhance CodSpeed benchmarking with heavy shard support

- Added a new marker for heavy benchmarks in `pyproject.toml` to categorize tests for the heavy CI shard.
- Updated GitHub Actions workflow to implement a matrix strategy for Python benchmarks, splitting them into "heavy" and "rest" shards based on execution time.
- Revised benchmark tests to utilize the new `heavy` marker, ensuring that the most resource-intensive benchmarks are appropriately categorized.
- Improved documentation in `benchmarks/README.md` to explain the new sharding strategy and its impact on CI performance.

---------

Co-authored-by: Max Halford <maxhalford25@gmail.com>
…ml#1923)

* feat(covariance): add online EWA and shrinkage covariance/precision estimators

Add a family of online covariance estimators reimplemented from the
`precise` package, following River conventions (dict-native, Narwhals
mini-batches) and excluding any lazy invert-on-read methods:

- covariance.EwaCovariance: exponentially weighted covariance (RiskMetrics
  style); diagonal matches stats.EWVar, off-diagonals match stats.EWCov.
- covariance.LedoitWolfCovariance / OASCovariance: data-driven shrinkage
  towards a scaled identity for high-dimensional / few-sample regimes.
- covariance.ShrunkCovariance: fixed-intensity shrinkage with a
  constant-correlation (finance) or identity target.
- covariance.EwaPrecision: exponentially weighted precision via a
  forgetting-factor Sherman-Morrison update; genuinely online, never
  inverts explicitly. Recency-weighted counterpart of EmpiricalPrecision.
- stats.EWCov: exponentially weighted covariance primitive (bivariate
  counterpart of stats.EWVar).
- datasets.SP500Stocks: daily returns of ten large-cap S&P 500 stocks
  (2013-2018), used in the docstring examples.

Internals are array-backed with a feature->index map (like
EmpiricalPrecision) behind a dict-native interface. Also guards
SymmetricMatrix.__repr__ against empty (unfitted) matrices.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(covariance): use independent references for covariance tests

Replace the circular EWCov test (which re-implemented the estimator's own
E[xy]-E[x]E[y] recursion) with a comparison against pandas' ewm().cov(), and
add a test comparing EmpiricalCovariance against sklearn's batch estimator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(covariance): simplify new covariance tests

Drop pytest.importorskip in favour of a plain inline sklearn import (matching
the existing sklearn test), extract the _dense value helper to module level,
and trim the EWCov comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(covariance): make EmpiricalCovariance/EmpiricalPrecision update_many narwhals-native

Migrate the empirical estimators' `update_many` off the hard-coded pandas
path (`.values`/`.columns`) to the `utils.dataframe` narwhals boundary helpers,
matching the new EWA/shrinkage estimators and the rest of the online-ml#1919 migration.
Any narwhals-supported eager dataframe (pandas, polars, pyarrow, ...) now flows
through; the pandas path is byte-for-byte unchanged. Adds multi-backend tests
via the `frame_backend` fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tector (online-ml#1929)

Replace the incremental-LOF bookkeeping (nine threaded dicts + free
functions) with a small class that delegates storage and neighbor search
to a `river.neighbors` engine (LazySearch by default, SWINN for
approximate search).

- `learn_one` is now O(1): it appends to a bounded sliding window, so
  memory no longer grows with the stream (was super-linear before).
- `score_one` computes the LOF against the current window on demand and
  no longer mutates the model. A point is never its own neighbor, so an
  unseen point reproduces scikit-learn's `LocalOutlierFactor(novelty=True)`
  and a stored point reproduces the in-sample `negative_outlier_factor_`
  (matched to ~1e-15). Per-score memoization keeps it ~6x faster.
- `learn_many` accepts any narwhals-supported eager dataframe (pandas,
  polars, pyarrow, ...) instead of pandas only. Addresses the
  anomaly/lof.py item of online-ml#1919.

Along the way, fix a pre-existing bug in the Rust Euclidean fast path of
`neighbors.LazySearch`: its search heap was keyed on the negated distance,
so it returned the *farthest* k candidates instead of the nearest. This
affected KNNClassifier/KNNRegressor/LOF on a LazySearch engine with the
default distance. Add a regression test (test_lazy.py).

Switch the shared `check_roc_auc` anomaly check to score-then-learn so it
no longer leaks the label by scoring an already-learned point. KNNRegressor
now skips check_shuffle_features_no_impact (its weighted average is
sensitive to float summation order under feature reordering, like the
forest models); LOF joins the automated estimator checks.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* update pyproject - mypy should take rust bindings into account

* add _river_rust.stats stubs

* add _river_rust.draft stubs

* add _river_rust.tree stubs

* add _river_rust.vectordict stubs

* add _river_rust.feature_hashing stubs

* add __init__.pyi for package detection

* unit test for keeping stubs and runtime in sync - v0, only stats for now

* solve remaining mypy errors

* format

* add missing instance check

* test stub-runtime sync for all stubs

* revert changes in Optimizer + add type: ignore comment

* format$
…-ml#1954)

`proba.Beta.n_samples` computed `self._alpha - self.alpha + self._beta -
self.beta`. `_alpha`/`_beta` are the frozen initial parameters while
`alpha`/`beta` grow by one on each `update()`, so the expression equals
`-(#successes + #failures)` — the negative of the number of observed
samples. A fresh `Beta().update(True)` five times reports `n_samples ==
-5`.

`n_samples` is the abstract `proba.base.Distribution` contract ("The
number of observed samples") and every sibling upholds it as a
non-negative count: `Gaussian.n_samples` returns `.mean.n`,
`Multinomial.n_samples` returns `sum(counts)`. Swap the two operands so
Beta counts its observed updates too (priors excluded, matching the
initial value of 0).

Verified: no in-tree consumer reads `Beta.n_samples`, so this only
corrects the public property's sign. Added an `n_samples` line to the
existing Beta doctest (it fails with `-300` before, passes with `300`
after).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nline-ml#1953)

BalancedAccuracy.get divided the recall sum by len(self.cm.classes),
which is the union of true and predicted labels. A class that only ever
appears in the predictions has no support: its recall is undefined
(0/0 -> ZeroDivisionError, skipped in the sum) but it was still counted
in the denominator, so the score was deflated.

Count only the classes whose recall is defined, matching
sklearn.metrics.balanced_accuracy_score, which excludes such classes.

BalancedAccuracy was never registered in the scikit-learn equivalence
test suite, which is why this went unnoticed; it is now added there
alongside a targeted regression test.

Signed-off-by: chuenchen309 <48723787+chuenchen309@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ose mini-batching via narwhals (online-ml#1932)

* feat(preprocessing,compose): make StandardScaler & compose mini-batching dataframe-agnostic via narwhals

Route StandardScaler's and the composition primitives' mini-batch methods through
the same narwhals boundary as the GLM/OneHotEncoder paths, so a whole pipeline can
be mini-batched on any narwhals-supported eager backend (pandas, polars, pyarrow,
nullable/arrow-backed pandas, ...). The numpy compute cores are untouched and the
input backend (including the pandas index) is rebuilt on output.

preprocessing/scale.py — StandardScaler:
- learn_many wraps via into_frame and drops to a float64 numpy matrix; the windowed
  branch iterates rows backend-agnostically.
- transform_many keeps a verbatim classic-pandas fast path (in-place divide, no-copy
  frame, float-dtype preservation) and adds an agnostic float64 path for every other
  backend. Pandas output is byte-for-byte unchanged.

compose:
- Pipeline: type hints only — the orchestration was already backend-agnostic.
- TransformerUnion: pd.concat(axis=1) -> nw.concat(how="horizontal").
- Select: X.loc[...].copy() -> narwhals select (still pure).
- TransformerProduct: keeps the pandas Sparse[uint8] fast path, adds an agnostic
  elementwise-product path for other backends.

Adds cross-backend tests (mixed-dtype TrumpApproval, chunked learning, emerging/
disappearing/reordered features, native-backend round-trip) using the frame_backend
fixture. Pandas remains the oracle.

Refs online-ml#1919.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(utils): update transform_many pandas-requirement test for narwhals StandardScaler

`StandardScaler.transform_many` no longer imports pandas unconditionally: it only
needs pandas on the classic-pandas fast path. The old test passed `object()` (which
now fails at the narwhals boundary, and isn't a valid `IntoDataFrameT`). Split it
into two: a pandas input still raises ImportError when pandas is missing, while a
polars input goes through the agnostic path and works without pandas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(utils, preprocessing): Build native frames directly from 2D numpy, simplify `StandardScaler` (online-ml#1935)

* Allow to create dataframe directly from 2d numpy array

* fixup square array issue

* simplify StandardScaler

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Francesco Bruzzesi <42817048+FBruzzesi@users.noreply.github.com>
…online-ml#1955)

`Pipeline.transform_many` gated the learn-during-predict step on
`not last_step._supervised and not self._LEARN_UNSUPERVISED_DURING_PREDICT`.
The second `not` is inverted relative to both the single-instance twin
`transform_one` (which uses `... and self._LEARN_UNSUPERVISED_DURING_PREDICT`)
and to `transform_many`'s own intermediate-step loop. As a result, with
`learn_during_predict` off (the default) `transform_many` fits the final
unsupervised transformer on the very data it is transforming:

- a bare `transform_many` mutates fitted state (a fresh scaler ends up
  with counts == len(X)), while `transform_one` leaves it untouched;
- the normal `learn_many` -> `transform_many` loop double-learns the
  final step;
- `transform_one` and `transform_many`, documented as the same
  operation, disagree.

Drop the erroneous `not` so the batch path matches the single-instance
path and the intermediate steps.

`test_product.py::test_issue_1253` relied on the bug: its model was
never trained, so the only thing that ever fitted the nested
`StandardScaler` was the fit-during-transform. Adding an explicit
`model.learn_many(X, y)` before the transform reproduces the exact same
expected output (fitting the same rows twice left mean/variance
unchanged), so the expected block is untouched.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ne-ml#1952)

* Stop PreviousImputer.transform_one from mutating the input dict

PreviousImputer.transform_one filled missing values directly on the
caller's dict (`x[i] = ...`), so it mutated its input and returned an
alias. Transformers are supposed to be pure -- the sibling StatImputer
already copies (`x = x.copy()`) with that exact comment. The estimator
purity check (check_predict_one_pure) never caught it because its dataset
has no missing values, so the None branch was never exercised. The
mutation is observable: compose.TransformerUnion feeds the same dict to
every branch, so an imputer running first changes what later branches see.
Copy the features first, mirroring StatImputer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: chuenchen309 <48723787+chuenchen309@users.noreply.github.com>

* add PreviousImputer to global tests

---------

Signed-off-by: chuenchen309 <48723787+chuenchen309@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Max Halford <maxhalford25@gmail.com>
Online Gamma-Poisson factorization of character n-gram counts, for
encoding messy string categories (hand-typed city names, and the like).
It's the one-at-a-time version of skrub's GapEncoder: the n-gram
vocabulary and the topics both grow as strings arrive, topic updates use
A/B accumulators with a rho forgetting factor, and transform_one is
read-only. Passes check_estimator.

Closes online-ml#1439
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants