Skip to content

Single-owner dataset statistics: pure stats functions, unified transform init, on-disk stats cache#786

Open
stefaanhessmann wants to merge 15 commits into
sa/dataset_refactorfrom
sa/stats_refactor
Open

Single-owner dataset statistics: pure stats functions, unified transform init, on-disk stats cache#786
stefaanhessmann wants to merge 15 commits into
sa/dataset_refactorfrom
sa/stats_refactor

Conversation

@stefaanhessmann

@stefaanhessmann stefaanhessmann commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to the dataset refactor (#781), executing the statistics refactor plan (REVIEW_FIXES.md items S1/D3). Statistics become a pure function of (base dataset, train indices) with a single owner and one query interface, and are persisted next to the split file.

What changed

  1. Pure-function statisticscalculate_stats / estimate_atomrefs take an optional indices parameter and build their own raw (transform-free, split-free) view internally. "Statistics are computed on raw data" is now the functions' contract, enforced in one place; the shallow-copy trick in the provider is deleted.
  2. One initialization path — transforms are initialized through a single initialize(stats) taking any object with get_stats/get_atomrefs (the provider or the datamodule). The Transform.datamodule() hook is hard-deleted; AtomisticModel.initialize_transforms passes the datamodule as the stats source. The strict no-estimation atomref case is an explicit estimate=False mode of the atomref query, replacing the side-channel atomrefs= argument.
  3. Disk cache next to the split file — computed stats and estimated atomrefs are persisted to <split>_stats.npz, keyed by a fingerprint of the train partition, read-modify-written while holding the same inter-process lock as split creation (held across miss→compute→write, so concurrent DDP ranks compute each entry at most once). On by default; stats_file=None opts out. Dataset-provided atomrefs are never persisted. Unreadable cache files fall back to recompute-and-overwrite.

The cornerstone test (commit 1) ports the review's end-to-end S1 check into the suite: RemoveOffsets and a late-initialized AddOffsets postprocessor must hold the identical, independently recomputed raw training mean. It passes on the base branch and on every commit of this PR.

Commits

Ten planned commits (tests-first, each leaving the suite green — verified by running the full suite at every commit), plus black formatting, two hardening fixes from a pre-PR review pass (lock held across compute; corrupt-cache tolerance), and two commits from a post-implementation audit: a stats-file extension fix (a user-supplied stats_file without .npz was written by np.savez under the appended extension but read back at the verbatim path, so the cache silently never hit) and a literal regenerated-split invalidation test.

Known limitations (deliberate)

  • The splitting/stats lock file remains cwd-relative (inherited from the existing split lock); processes with different working directories sharing one stats file take different locks.
  • The fingerprint covers (dataset length, train partition) — regenerating the database in place with identical length would not invalidate persisted stats, same as it would not invalidate split.npz. Delete the stats file or set stats_file=None in that case.
  • Persisted stats entries are scalar per (property, flags) — matching calculate_stats, which reduces each property to scalar mean/std.

Test plan

  • pytest tests/: 86 passed, 3 skipped (base branch: 60 passed, 3 skipped); suite verified green at each individual commit.
  • black --check . clean with the pinned black==24.4.2.
  • New tests: e2e offset consistency incl. late postprocessor init (S1 regression), raw-view guarantee of the stats functions, provider pure interface, strict atomref mode, fingerprint determinism/invalidation, disk cache read-instead-of-recompute, split-regeneration invalidation, opt-out, never-persist dataset atomrefs, corrupt-cache recovery, non-.npz stats-file round-trip.

🤖 Generated with Claude Code

stefaanhessmann and others added 15 commits July 22, 2026 17:08
Turn the out-of-tree synthetic-dataset script used to verify review
finding S1 (wrong offset statistics for late-initialized model
postprocessors) into proper tests: RemoveOffsets and a late-initialized
AddOffsets postprocessor must hold the identical, independently
recomputed raw training mean and estimated atomrefs, and the data
pipeline must actually remove that mean.

Guard rail for the upcoming statistics refactor.

Co-Authored-By: Claude Fable 5 <[email protected]>
calculate_stats and estimate_atomrefs accept an optional indices
parameter. When given, they internally build an index-restricted raw
view of the passed dataset (no transforms, no split label) and iterate
over that — 'statistics are computed on raw data' becomes part of the
functions' contract instead of a caller convention.

Existing callers are untouched. The shared synthetic-dataset fixture
moves to tests/data/conftest.py.

Co-Authored-By: Claude Fable 5 <[email protected]>
…indices

The provider now calls the stats functions with explicit indices; the
raw-view guarantee lives in those functions' contract, so the
shallow-copy/strip-transforms trick is deleted. The datamodule passes
the base dataset and the train index list it already has at setup time.
Public get_stats/get_atomrefs are unchanged.

Co-Authored-By: Claude Fable 5 <[email protected]>
get_atomrefs(property, is_extensive, estimate=True) on both the
provider and the datamodule: with estimate=False, only dataset-provided
atomrefs are returned, and a clear error is raised if they are absent.
Prepares replacing the side-channel atomrefs= argument of transform
initialization.

Co-Authored-By: Claude Fable 5 <[email protected]>
…ument

Transforms' initialize() now takes one duck-typed stats source (anything
with get_stats/get_atomrefs — the provider or the datamodule). The
strict no-estimation atomref case goes through the stats source's
estimate=False mode, so the side-channel atomrefs= argument and the
dataset's atomref read in initialize_transforms are gone. The
datamodule() hooks are untouched in this commit.

Co-Authored-By: Claude Fable 5 <[email protected]>
AtomisticModel.initialize_transforms now calls the unified
initialize(stats) with the datamodule, which satisfies the stats-source
protocol and shares one cached provider with the data-pipeline
transforms. The per-transform datamodule() hooks and the base-class hook
are removed (hard removal: this release already breaks the datamodule
constructor API, so downstream transforms migrate once).

The ported end-to-end tests (late postprocessor initialization) pin the
model path to identical raw-training-data offsets.

Co-Authored-By: Claude Fable 5 <[email protected]>
The datamodule computes a short deterministic fingerprint of the train
index list (sorted, plus dataset length) whenever it creates or loads a
split. No consumer yet; it will key the persisted statistics.

Co-Authored-By: Claude Fable 5 <[email protected]>
The provider takes an optional stats file plus the train-partition
fingerprint. On an in-memory miss it first reads the file; entries are
keyed by the query triple and only valid if the stored fingerprint
matches. Computed entries are written back in a read-modify-write under
the same inter-process lock as split creation, so concurrent DDP ranks
compute each entry at most once.

The datamodule derives the default stats path from split_file
(<split>_stats.npz); stats_file=None disables persistence entirely.

Co-Authored-By: Claude Fable 5 <[email protected]>
Same stats file, same fingerprint rule, keyed per property and
extensivity flag. Dataset-provided atomrefs are never persisted — they
already live in the database metadata.

Co-Authored-By: Claude Fable 5 <[email protected]>
Add estimate_atomrefs and StatsAtomrefProvider to the data API docs,
describe the datamodule's statistics ownership and on-disk persistence
in the user guide overview, and sweep the remaining docstrings that
referenced the removed datamodule() hook path.

Co-Authored-By: Claude Fable 5 <[email protected]>
Hold the inter-process lock across the whole miss-compute-write cycle,
so concurrent DDP ranks compute each entry at most once instead of
merely serializing their writes. The lock file name becomes a shared
constant (SPLITTING_LOCK) so the datamodule's split creation and the
provider's stats persistence cannot drift apart.

Co-Authored-By: Claude Fable 5 <[email protected]>
A cache file left corrupt by a crash mid-write must never break
setup(): load failures fall back to recompute-and-overwrite.

Co-Authored-By: Claude Fable 5 <[email protected]>
np.savez appends ".npz" to filenames missing it, while the read path
checked the verbatim path — a user-supplied stats_file without the
extension was written but never read back, silently recomputing
statistics on every run. Normalize the path once in the provider.

Co-Authored-By: Claude Fable 5 <[email protected]>
The plan's testing decision reads "regenerated split -> fingerprint
mismatch -> recompute"; cover it literally at the datamodule level:
delete split.npz, set up a fresh datamodule with a different partition,
and assert the stored statistics are recomputed rather than served.

Co-Authored-By: Claude Fable 5 <[email protected]>
@stefaanhessmann

Copy link
Copy Markdown
Collaborator Author

Review summary (post-implementation audit)

Audited the branch against REFACTOR_PLAN_STATS.md (10 planned commits → 13 actual, mapped 1:1 plus black + two hardening fixes) and against the base tip 9ca9186.

Verified:

  • Plan fidelity — indices contract with the raw view built inside calculate_stats/estimate_atomrefs; provider constructed from (base dataset, train indices) with the shallow-copy trick gone; strict estimate=False atomref mode; single initialize(stats) path with the datamodule() hook fully deleted (no leftovers in transform/base.py, model/base.py, or docs); fingerprint over (dataset length, sorted train indices); persistence keyed by query triple + fingerprint under the shared SPLITTING_LOCK; stats_file=None opt-out; dataset-provided atomrefs never persisted.
  • Concurrency — no nested acquisition of the inter-process lock on any call path (_load_partitions releases it before the provider exists), so the POSIX release-drops-all hazard is not reachable; torn np.savez writes are only observable after a crash and are handled by the corrupt-file fallback; compute-under-lock gives at-most-once semantics per entry across ranks. The cwd-relative lock path remains a documented limitation.
  • Tests — every Testing Decisions bullet is covered, all asserting external behavior only (no test touches internal caches). Full suite green at each of the 13 commits (63 → 84 tests, now 86 with the audit commits); black --check clean with the pinned 24.4.2.

Found & fixed (pushed to this branch):

  • 8e5fa6e's parent e4eac8e — a user-supplied stats_file without .npz was written by np.savez under the appended extension but read back at the verbatim path: the cache silently never hit. Normalized in the provider + regression test.
  • 8e5fa6e — the plan's "regenerated split → fingerprint mismatch → recompute" decision is now tested literally at the datamodule level (delete split.npz, new partition, assert recompute).

Remaining review notes are style-level judgement calls (mirror-image RemoveOffsets/AddOffsets initialize bodies — pre-existing shape, net reduced; provider combines stats + persistence concerns) — none blocking.

Verdict: ready to undraft.

🤖 Generated with Claude Code

@stefaanhessmann
stefaanhessmann marked this pull request as ready for review July 22, 2026 15:53
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.

1 participant