Clustering primitives and persistence¶
The backend-agnostic pieces every clusterer shares: the PCA embedding, the
deployable classifiers, the per-element fit contract, and PickleIO — the
one implementation behind Judge.save / Judge.load and the
judge_final.pkl a run writes.
atom_hifi/training/cluster_base.py — backend-agnostic clustering primitives.
The pieces every cluster backend shares, independent of which clusterer runs:
the PCA embedding (PCAEmbedder), the deployable classifier
(NearestNeighborClassifier), the per-element result contract
(ClusterFit), and the trailing-noise-slot promotion machinery
(issue #63). Backend implementations live in
atom_hifi.training.cluster_backends; this module must not import them
(the dependency runs one way, backends -> base).
Split out of cluster_backends.py in issue #69; the native classifier landed
in issue #76.
- atom_hifi.training.cluster_base.NOISE_FRAC_WARN = 0.3¶
HDBSCAN noise fraction above which the softness/superionic warning fires (cf. LLZO Li noise-frac 0.42 in the §39 three-way study).
- atom_hifi.training.cluster_base.CHUNK_MAX_EL = 50000000¶
distance-matrix element budget per prediction chunk (a test shrinks this)
- class atom_hifi.training.cluster_base.Embedder[source]¶
Bases:
ABCDescriptors → the metric space the clusterer and δ both live in.
Closed on purpose — do not add a second implementation. δ = c·ε_noise, the
|S|targets and the QHO σ table are all distances in decaf’s PCA metric (broken-stick dim, uncentred projection). A different embedder is a change of units, not a swap, and no artifact records which embedder produced it — caches tag the backend only. Embedder-tagging is the prerequisite for opening this up, not a detail.- abstract property t_embedded¶
(N, dim) embedded training rows.
- abstract property embed_op¶
(D, dim) projection operator;
embedis a matmul with it.
- class atom_hifi.training.cluster_base.Classifier[source]¶
Bases:
ABCAssigns novel embedded rows to fitted groups, abstaining when far.
Internal abstraction. The mechanism is backend-agnostic and, since issue #76, actually written that way — see
NearestNeighborClassifier. Loop and patient OOD consume either supported mechanism. Concretely:decaf sets
ClusterFit.abstain_radius-> the 1-NN-over-training-points rule (NearestNeighborClassifier).kmeans sets
ClusterFit.abstain_lambda-> the nearest-centroid rule priced by the DP-means cluster-opening price (gitlab#80,NearestCentroidClassifier). kmeans’ granularity is still a cluster count, but the FITTED PARTITION has a resolution, andlambda_openprices it — that is what changed.hdbscan stores frozen density hierarchy prediction state. It uses mutual-reachability attachment, not a radius or a centroid price.
ClusterBackend.has_classifieris True for all three built-ins. Kmeanslambda_opentracks local partition spacing, so when k grows with the corpus its OOD rate can stay approximately scale-free and the loop may consume the full library. Gitlab#81 accepts that existing termination behavior;HiFiWorkflow.run_loop()warns once instead of changing the predictor or inventing an unvalidated stop threshold.- abstractmethod classify(embedded, **kwargs)[source]¶
Return decaf’s
(gID_distinct, gID_label_all)pair.Both lists have one slot per selection group plus a trailing slot.
gID_distinct[-1]holds rejected rows. DECAF and KMeans provide unconditional assignments ingID_label_all(empty trailing slot). HDBSCAN has no unconditional assignment: rejected rows also occupy its second list’s trailing slot. Promoted noise slots stay empty.
- class atom_hifi.training.cluster_base.ClusterBackend[source]¶
Bases:
ABCThe clusterer, and the one public extension point.
Capabilities are class attributes, not name literals: code asks what a backend can do, never what it is called.
nameis the exception — it is the cache tag, andstate_ioenforces equality on it across runs.- has_auto_bandwidth: bool = False¶
fit yields its own bandwidth estimate (decaf’s HaarWavelet δ_auto)
- abstractmethod fit(desc_el, granularity, *, t_embedded=None, **kw)[source]¶
Cluster one element; return an unpromoted
ClusterFit.Takes raw
desc_elrather than a ready embedding because decaf builds its own embedding internally —inject_precomputedneeds the decaf object itself. Backends that cluster in the shared embedding should callshared_embedder()and use itst_embedded; that is the one-liner a custom backend needs.
- classmethod unsupported_reason(granularity=None)[source]¶
Why this backend cannot run in this environment, or None if it can.
The capabilities above are static facts about the algorithm; this one is not. It reports a dependency fault — a broken upstream version, a missing optional package — so it has to be asked at runtime rather than read off the class.
Two questions, one method:
granularity=Noneasks the general one — can this backend be relied on here at all? Scan entry points and the test skip marker use this, because a scan sweeps δ and will reach the bad values.passing a δ asks about that one fit, which lets a backend admit the δ values it can still serve.
fit_cluster_backenduses this, so a dependency fault that only bites part of the δ range does not take the whole backend down with it.
The shared PCA embedding: reuse the SHM-served one, else fit it.
- class atom_hifi.training.cluster_base.PickleIO[source]¶
Bases:
objectPickle persistence with a declared footprint, shared by all backends.
A mixin, not a fourth abstraction: it declares no abstract method, so it stays a plain base class and leaves its subclasses’ metaclass alone.
Backend-neutral by construction: pickle serialises whatever the object holds, so one implementation here covers decaf, kmeans, hdbscan and any registered custom backend with no per-backend code — the reason it lives beside the abstractions rather than on
Judge.What it adds over a bare
pickle.dumpis the footprint contract. A fit artifact keeps references to arrays that are inputs, not results (raw descriptors above all), and pickling them silently turns a 10 MB object into a 300 MB file. Subclasses list those attributes in_PICKLE_DROP; they come back asNoneand the loader is expected to say so. Saves stamp the schema and package version so a stale file is diagnosable rather than mysterious.The footprint applies to serialization only.
__getstate__also drivescopy.copy/copy.deepcopy, so without the overrides below a caller who merely copied a fitted object would silently lose the dropped attributes on a live object no one asked to persist. Those overrides assume__dict__-based subclasses, which is whatJudgeis.Serialization never computes: these objects also travel over joblib IPC, where a hook that materialised a lazy field would cost real throughput.
- PICKLE_SCHEMA = 1¶
Bump when a load of an older file would be wrong rather than merely thin. 1 — first stamped format;
_PICKLE_DROPattributes restore as None.
- save(path)[source]¶
Pickle this object to
path(footprint per_PICKLE_DROP).Written to a temporary sibling and moved into place with
os.replace(), becauseopen(path, 'wb')truncates first: a save that failed halfway — the very case the workflow’s save site anticipates for an unpicklable custom backend — would otherwise destroy the artifact a previous run wrote and leave a truncated file that fails at load time instead of here.
- classmethod load(path)[source]¶
Unpickle an object saved by
save().A file older than the schema is loaded, not rejected: the whole point of the artifact is to survive the run that wrote it, and every migration so far has been additive. A mismatch warns so the reader knows which version’s semantics they are looking at.
- class atom_hifi.training.cluster_base.PCAEmbedder(t_embedded, embed_op=None, desc_for_lazy=None)[source]¶
Bases:
Embedderdecaf PCA embedding, computed once per element and shared by all backends.
embedis a pure matmul withembed_op(decaf’sembed_PCA), so a precomputed (t_embedded, embed_op) pair reproduces decaf’s embedding exactly.On the SHM fast path only
t_embeddedis available;embed_opis then recomputed lazily from the raw descriptors on first access (scan workers never touch it, so the extra PCA is never paid in the hot loop).- property t_embedded¶
(N, dim) embedded training rows.
- property embed_op¶
(D, dim) projection operator;
embedis a matmul with it.
- class atom_hifi.training.cluster_base.NearestNeighborClassifier(t_embedded, labels, n_groups, radius)[source]¶
Bases:
Classifier1-NN label transfer over training points + radius abstention.
The native implementation ADR-0011’s abstraction table names. It replaces the delegation to decaf, and is a verbatim port of
MSC_classifyon the one path atom_hifi uses (classify_mode='TrainingData',label_all='Both';decaf.py:1510-1554) — not a reimplementation of the idea. ADR-0011 left this open precisely because a loose port moves OOD counts silently, so three conventions are copied rather than improved:``argsort(axis=1)[:, 0]``, not ``argmin`` (
decaf.py:1508/1518). numpy’s default sort is not stable, so on an exact distance tie the two may resolve to different training atoms — and hence different groups. Whether they actually differ is numpy-version-dependent (2.2 diverges on an all-tied row, 2.4/2.5 do not), which is precisely why the call is copied rather than reasoned about: copying makes this class agree with decaf on every numpy, including future ones where the tie-break changes again. Exact ties are not hypothetical in a symmetric crystal.argminwould also be cheaper in memory; that trade is deferred to gitlab#77, which measures tie frequency first.``<=``, not ``<`` for the radius test (
decaf.py:1542).The abstention test is over the training set, not the centers, and is therefore independent of how the partition was drawn (
:1540-1542). That independence is exactly why this class is backend-agnostic.
Inputs are
(t_embedded, labels, n_groups, radius). Notelabelsis the fit’s promoted label array, where decaf readst_gIDthroughreformat_groupID. The two agree on the production path —MeanShiftruns withcluster_all=True(decaf.py:1434) so no atom is ever-1and the trailing slot stays empty — but they would not agree after issue-#63 promotion renumbered a backend that does emit noise. Using the promoted labels is what keeps a promoted fit self-consistent.- classify(embedded, dist_in=None, **kwargs)[source]¶
Return decaf’s
(gID_distinct, gID_label_all)pair.dist_inis a precomputed(N_new, N_train)distance matrix. Both “absent” spellings are accepted:None, and decaf’s own empty-array sentinel (decaf.py:1504/1511), so a call ported straight off a decaf one does not land on a shape error deep insideargsort. A present matrix of the wrong shape is rejected, which decaf does not do — see the branch below for why that cannot move a number.The multiscale arguments are deliberately NOT accepted, because nothing in atom_hifi passes them and quietly ignoring a multiscale request would answer a different question than the caller asked.
- class atom_hifi.training.cluster_base.NearestCentroidClassifier(centers, n_groups, lambda_open)[source]¶
Bases:
ClassifierNearest-centroid label transfer + DP-means opening-price abstention.
The kmeans counterpart of
NearestNeighborClassifier. The assignment rule IS kmeans’ own definition of a label —argmin_j ||x - mu_j||— which is the point: the 1-NN-over-training-points rule assigns a DIFFERENT cluster on 10-41% of the queries both rules resolve (gitlab#80 Stage A, measured on 7 real element arms across 3 systems), so a kmeans fit cannot be answered through NearestNeighborClassifier.Abstention uses the DP-means cluster-opening price: q(x) = d1**2 / lambda_open, with q == 1 EXACTLY in distribution (STRICTLY greater means OOD). Note the units: lambda_open is in SQUARED-distance units — d1**2, not d1, is what is compared to it.
- predict(embedded, return_confidence=False)[source]¶
Centroid rule: assign nearest centroid, abstain where q > 1.
q(x) = d1**2 / lambda_open, with the boundary q == 1 exactly IN distribution (STRICTLY greater means OOD). d2 is the second-smallest distance (inf and confidence 1.0 when k == 1); confidence is
1 - d1/d2, 0 where d1 == d2 == 0. Distances are computed in row chunks (each chunk’s (rows, k) matrix under CHUNK_MAX_EL elements) so memory stays bounded; d1 and d2 come from the SAME distance pass.Returns a dict with
label(-1 where OOD),assigned(the raw nearest-centroid index, never -1),d1,d2,q,confidence,oodandood_fraction.d2andconfidenceare None unlessreturn_confidence.
- class atom_hifi.training.cluster_base.ClusterFit(labels, n_groups, centers, granularity, backend, embedder, classifier=None, noise_derived=None, pre_promotion_noise_frac=None, abstain_radius=None, abstain_lambda=None, abstain_calibration=None, hdbscan_prediction=None)[source]¶
Bases:
objectPer-element clustering result — the backend-agnostic contract.
Fits returned by
fit_cluster_backend()are self-consistent: every label is in[0, n_groups),centersandnoise_derivedhaven_groupsrows, and the trailing noise/distinct slot is empty.- Parameters:
- classifier: object = None¶
Optional
Classifieroverride. Since issue #76 no backend sets it:classify()builds aNearestNeighborClassifierfromabstain_radiusinstead, so nothing stateful rides on the fit. It stays honoured first, which keeps an explicitly-supplied implementation — and any pickle written before #76 — working unchanged.Renamed from
decaf_clusterin issue #69 — deliberately a NEW name, not a repurposed one. A pickle written between #68 and #69 still unpickles (a dataclass restores __dict__ wholesale, so the staledecaf_clusterkey simply rides along); had this field kept that name, the stale value would satisfy the is not None test and the fit would classify through a stale decaf object with no error at all. Under the new name such a fit raises, which is the honest outcome.
- abstain_radius: float = None¶
Radius for the 1-NN abstention test, or None when this backend has no deployable classifier. It carries the policy, which is why it is a field and not derived from
granularity: for decaf the two coincide (δ IS decaf’s radius), but kmeans’ granularity is a cluster count with no distance meaning.DecafBackendsets this, kmeans setsabstain_lambda(gitlab#80), and hdbscan stores density state instead.
- abstain_lambda: float = None¶
DP-means cluster-opening price in SQUARED-distance units (gitlab#80), set by
KMeansBackend.fitonly. It is a SEPARATE field fromabstain_radiusand not a unit conversion of it, because the two select different classifiers — nearest-centroid vs 1-NN-over-training points — and Stage A measured those two disagreeing on 10-41% of jointly-resolved queries.ClusterFit.classifyraises if both are set, rather than silently preferring one.
- abstain_calibration: dict = None¶
Provenance dict returned by
lambda_local(gitlab#80), persisted so a fit’s abstention threshold can be audited after the fact.
- hdbscan_prediction: object = None¶
Versioned frozen density state, built from sklearn’s fitted hierarchy. Separate from promotion: its labels remain backend-local density ids.
- property n_density_groups¶
Coverable groups produced by clustering, excluding noise promotion.
- property n_deployable_groups¶
Number of targets supported by this saved fit’s prediction state.
- classify(embedded, **kwargs)[source]¶
Assign novel embedded rows to this fit’s groups, abstaining when far.
The classifier is built here rather than stored on the fit — whether
NearestNeighborClassifier(fromabstain_radius) orNearestCentroidClassifier(fromabstain_lambda). It holds only references plus a float, so construction is O(1) and there is nothing to amortise — whereas a cached instance would put a second reference tot_embeddedon an objectJudge.savepickles. This is also what closes ADR-0011’s open pickle-footprint item: no live decaf object needs retaining on a fit any more.classifieris still honoured first, so an explicitly-supplied implementation (and any pickle written before this change) keeps working.
- property noise_frac¶
Fraction of atoms the backend put in the trailing slot.
Reads the pre-promotion value when promotion has run, so the softness signal survives noise reps becoming real groups (issue #63).
- atom_hifi.training.cluster_base.dedup_noise_greedy(coords, delta)[source]¶
Greedy single-pass dedup of noise atoms at radius δ (= ε_noise).
Walk the noise atoms; keep one as a DISTINCT rare-environment rep if it is farther than δ from every already-kept rep, else it is a thermal duplicate absorbed into that rep. Deterministic (natural order). O(n_noise · n_kept).
- Returns:
kept_idx (list[int]) – Indices (into
coords) of the kept distinct reps.assign (ndarray, shape (len(coords),)) –
assign[i]is the position within ``kept_idx`` of the rep thatcoords[i]belongs to — identity for kept reps themselves. Used to absorb thermal duplicates into their rep’s group rather than discarding them (issue #63).
- atom_hifi.training.cluster_base.promote_trailing_slot(labels, n_groups, centers, t_embedded, eps_noise)[source]¶
Dedup the trailing slot and append each survivor as its own group.
The backend-agnostic half of issue #63: a noise/distinct atom is a rare environment, not a non-result. Deduplicating at ε_noise and promoting each survivor to a real group makes those environments coverable by the ordinary set-cover, addressable by group id, and visible to the meta-group linkage — none of which was true while they lived in a
forced_idxside-channel.Thermal duplicates are absorbed into their kept rep’s group, so no atom is left unlabelled.
No-op when the trailing slot is empty, which is the case for kmeans (no such slot) and for decaf on the
classify_mode='TrainingData'path (every atom is its own neighbour at distance 0 ≤ bandwidth).- Return type:
labels, n_groups, centers, noise_derived, n_trailing