mixle.lifecycle module¶
mixle.Model – the model lifecycle as one object with consistent verbs.
Everything here exists elsewhere in the library; this facade makes the lifecycle discoverable without knowing which subpackage owns which verb:
m = mixle.propose(data) # a model shape recommended from the data (with confidence + caveats)
m.fit(data) # inference chosen from the structure (EM / MLE / closed form)
m.evaluate(holdout) # held-out scores
m.sample(5) # draw new records
m.enumerate().top_k(3) # most-probable support (discrete/structured families)
m.posterior(x) # latent posteriors (mixtures, HMMs, ...)
m.distill(teacher, inputs) # compact deployable student in front of the teacher (task spine)
m.deploy("artifacts/m") # durable artifact directory; Model.load() restores it
m.explain() # what it is, what it supports, and how it was proposed
m.explain_prediction(x) # exact per-part attribution of one score
m.forecast(history, h) # horizon predictions, model-predictive MC intervals (HMMs)
m.do({field: value}) # graph-surgery intervention (learned Bayesian networks)
m(x) # use it: log-density of an observation
Model wraps a prototype distribution, an estimator, or nothing (the estimator is inferred from the
data); verbs delegate to mixle.inference.optimize(), dist.enumerator(), mixle.task.solve,
and mixle.describe(). It adds no new inference – only one place to stand.
- json_read_back_failure(text)[source]
Why the JSON in
textwould fail to decode, orNonewhen it reads back cleanly.Encoding is not evidence of a round trip.
to_serializableaccepts any registered class’s__dict__, while the decoder additionally requires that state to reconstruct through the class’s own constructor – so a family whose estimator pins a fit annotation onto the fitted object, or whose state names a parameter differently from the constructor’s, encodes cleanly and then refuses to load. The only honest test of “can this be read back” is to read it back, against the exact text about to be written.The decode runs inside
trusted_deserializationon purpose. This payload was encoded from a live object in this very process moments ago; the trust gate exists to stop an artifact that arrived from somewhere else from executing code, and it has nothing to say about our own in-memory model. Probing untrusted would report every model carrying an embedded torch module as unreadable and demote a perfectly good JSON artifact to a pickle.
- class DeployedArtifact(path, *, format, format_fallback)[source]
Bases:
strThe artifact path
Model.deploy()returns, annotated with what was written there.A plain
str(everyos.path/Pathuse keeps working), plus the two facts a caller needs at the call site rather than by re-opening the manifest:format("json"or"pickle") andformat_fallback(Nonefor JSON; otherwise the sentence saying why this artifact is a pickle and therefore costs its readerstrust_code=True). A deploy pipeline can assertresult.format == "json"to enforce a no-pickle policy in one line.
- saddle_suspect(fitted, data, *, sample=200, tol=0.02)[source]
Family-agnostic symmetric-saddle check for latent-variable fits.
At the symmetric saddle every component is identical, so every observation’s component posterior is (numerically) uniform. Suspect when, over a data sample, NO observation’s posterior deviates from uniform by more than
tol. Non-latent models (noposterior/components) return False.
- class Model(spec=None, *, notes=None)[source]
Bases:
objectOne object over the model lifecycle: build / fit / evaluate / enumerate / distill / deploy / use.
- fit(data, *, restarts='auto', calibrate=False, **optimize_kw)[source]
Fit via
mixle.inference.optimize(); the algorithm follows from the model’s structure.The fit iterates EM to its tolerance: unless the caller passes
max_its=, the iteration cap defaults to 500 here (optimize’s own default of 10 regularly stops a mixture fit on the initialization plateau – 58 nats short of the optimum on Old Faithful – with no signal). Thedeltastopping rule ends a converged fit long before the cap; a fit that does hit the cap without converging is disclosed innotesand inself._fit_info(n_iter,converged, read from the model’sFitProvenance()receipt). Passingdelta=Nonethrough**optimize_kwrequests a fixed iteration count instead – seemixle.inference.optimize()’s owndeltadocs for the caveat that a rejected (non-improving) EM step still ends the loop early even then, whichoptimizediscloses via aUserWarning(notnotes) when it happens.A
specthat is a prototype distribution also supplies the EM starting point: its parameter values are honored as the initialization (optimize’s bare prototype coercion keeps only the structure and draws a random subsample start). Passprev_estimate=yourself to override the start explicitly.restarts="auto"(default) makes latent-variable fitting genuinely automatic: after the plain fit, a family-agnostic saddle check runs (a mixture stuck at the symmetric saddle gives every observation a ~uniform component posterior) – and a latent fit that exhausted its iteration cap without converging is treated as equally suspect. On suspicion the fit reruns from diversified initializations (hard-partition component starts, falling back tomixle.inference.best_of()), keeping the better log-likelihood and recording what happened innotes. Pass an int to force that many restarts up front, orrestarts=Nonefor the raw single fit.calibrate(opt-in, default off): reserve a holdout slice (a fraction, orTruefor 25%), fit on the rest, and attach aCalibrationReportonself.calibrationmeasuring calibration quality on held-out data (PIT test + held-out log-density). Off by default because it costs training data.Neither evidence step can break a fit, but neither silently disappears either:
self.evidencecarries a typedsucceeded/failed/not_applicablerecord per step, naming the failure class and message when one failed, and a failure also lands innotes(soexplain()shows it).certificate=Nonealone cannot tell you whether certification was never applicable or raised.
- evaluate(data)[source]
Held-out fit quality: total and mean log-density over
data.A
-infentry is not raised on the wayNaN/+infare (see below): it is not necessarily a scoring bug. The common, DOCUMENTED cause is a field auto-inference froze as an identifier-like/high-cardinality categorical (mixle.utils.automatic.get_estimator, also reached throughoptimize(df)’sestimator=Nonepath) meeting, here, a label its training split never saw –CategoricalDistribution’s documenteddefault_value=0.0scores that at-infby design. Silently averaging it intomean_log_densitywould still hide the cause, so this warns (naming the affected rows) rather than staying quiet – but does not raise, unlikeNaN/+inf, both of which are unconditionally scorer bugs.
- sample(size=None, *, seed=None)[source]
Draw samples from the fitted distribution.
- enumerate()[source]
The fitted distribution’s enumerator (top-k / top-p / rank / seek), where supported.
- Return type:
- posterior(x)[source]
Latent posterior probabilities for one observation (mixtures, HMMs, …), where supported.
A component-latent model (a mixture) answers with its own
posterior: the(k,)component probabilities forx. A chain-latent model (an HMM), whosexis one observation sequence, has noposteriormethod – the module docstring’s promise used to die there as a bareAttributeError– so it answers throughlatent_posterior: the(T, k)forward-backward smoothing marginals, one state distribution per timestep. The richer chain-posterior object (Viterbimode(), FFBSsample(),entropy()) stays available asmodel.fitted.latent_posterior(x).A model with no latent state (a plain Gaussian) raises
AttributeErrornaming the two supported shapes instead of the bare delegation failure.
- distill(teacher=None, inputs=None, **solve_kw)[source]
Distill a compact deployable student via
mixle.task.solve().With
teacher=Nonethe fitted model itself teaches: inputs are labeled by their most-probable latent component (posteriorargmax), so a fitted mixture becomes a fast, calibrated classifier of its own clusters. Returns amixle.task.Solution(call it,report(),improve()).
- deploy(path)[source]
Persist a durable artifact directory (model + manifest);
Model.load()restores it.A registry-serializable model is written as safe, type-tagged JSON (
model.json); a model the serialization registry cannot represent falls back to a pickle (model.pkl). “Pure statistical” does not guarantee “JSON”: besides the expected torch-backed leaves, any base-install family the registry has no JSON form for takes the pickle path too (the Bernoulli-set, Thurstone, and Spearman ranking families – modelspropose()itself selects for ordinary set-valued and ranking data – deployed that way until their 0.8.0 codec repair; they now write JSON). Two whole modules still fall back this way by design, not regression: every family inmixle.stats.directional(VonMises, WrappedCauchy, WrappedNormal, VonMisesFisher, Kent, Watson, Bingham, ProjectedNormal) andmixle.stats.matrix(Wishart, InverseWishart, MatrixNormal, LKJ) lacks the__pysp_setstate__hook a family needs to round-trip through JSON, sodeploy/loadalways take the pickle path for them today, pending a future implementation. Never assume the format: read it from the returned path’sformatattribute (the return value is aDeployedArtifact, a plainstrannotated withformatandformat_fallback), from the warning a fallback raises here, or from the manifest.“Cannot represent” is decided by trying it, not by predicting it: the JSON is decoded again before the artifact is accepted, so a model that encodes cleanly and then refuses to load – the state whole families were in, deployed successfully and readable by nothing – takes the pickle path instead of becoming a write-only artifact. Any such fallback is disclosed three ways rather than taken silently: a warning here,
format_fallbackin the manifest naming the model and the exact read-back error, and a note on the modelload()returns. It costs the reader atrust_code=True, so it should be visible; if that is unacceptable for a given family, the fix is a serialization hook on the family, not a quieter deploy.This is a model export, not an evidence export. The artifact carries the fitted distribution,
notes, and the fit record (n/when/artifact_hash) – and nothing else. TheEstimationCertificateoncertificate, theCalibrationReportoncalibrationand the candidatefrontierare not serialized and do not survive a round trip; a deployed model comes back able to predict but unable to show how it was chosen or how well it was calibrated. Whatever evidence was present at deploy time is listed in the manifest’sevidence_not_exportedand reported as a note on the loaded model, so the loss is visible rather than silent. Keep those receipts alongside the artifact yourself if a consumer needs to judge the model, not just run it.The model file is written first, then hashed, and the manifest naming it (
model_file) and binding itsmodel_sha256is promoted atomically last. A redeployment removes the previous generation’s other-format model file, so an artifact directory never holds a stalemodel.pklnext to a freshmodel.json, andload()re-checks the digest before deserializing anything.- Parameters:
path (str)
- Return type:
DeployedArtifact
- classmethod load(path, *, trust_code=False)[source]
Restore a
Modelfrom an artifact directory created bydeploy().Warning
A
pickle-format artifact (a torch-backed model, or one written by an older mixle) executes arbitrary code from the file when loaded, exactly likepickle.loadon any untrusted input. JSON-format artifacts are deserialized through a type-tagged registry that never imports or executes code from the payload – unless the model contains a NeuralLeaf-family component, whose weights are embedded as a pickle blob inside the otherwise-safe JSON (seemixle.models._neural_serial); loading one of those also executes code. There is no way to tell which case applies without attempting the load.Because of this,
loadrefuses to run any code-executing path unless the caller passestrust_code=True– an explicit statement that the artifact’s source is trusted. Without it, a pickle-format artifact raises immediately, and a JSON artifact raises only if it actually contains an embedded module (a pure-statistical model still loads normally).trust_code=False,Noneand0are all read as “no trust” (an artifact that needs no trust loads under any of them); any other value – a truthy string,1– is rejected loudly, because only the exactTruemay authorize code execution.When the manifest records a
model_sha256(every artifact written by this version does), the named model file is hashed and checked against it before anything is deserialized, so a model file edited, truncated or swapped after deployment – while the manifest stays intact – is refused rather than loaded. An older manifest without that field is loaded unverified, exactly as before – there is nothing to check it against.What these checks are, and are not. All of them read the manifest itself as ground truth: the digest binds the manifest to the model file’s bytes, the
familycheck binds its self-description to what those bytes deserialize to, and themodel_content_hashcross-check (when recorded) compares the deserialized model’s content hash against the one stamped at deploy time – a mismatch there means the manifest’s fit record and the model being served may not come from the same deployment, and it is disclosed as a warning and a note rather than a refusal (a content-hash recomputed by a different mixle version can legitimately drift; refusing would brick valid artifacts). None of this authenticates the manifest: a writer with write access to the artifact directory can rewrite the manifest and model together. Integrity against that threat needs a signature kept outside the directory (sign the artifact, or comparemodel_sha256against a digest recorded elsewhere).The restored
Modelcarries the fitted distribution,notesand the fit record. The certificate, calibration report and candidate frontier are not in the artifact at all (seedeploy()); when the deploying model had any of them, a note saying so is appended.
- explain_prediction(x)[source]
Exact per-part attribution of
log p(x)—mixle.inference.explain().- Parameters:
x (Any)
- forecast(history, horizon, **kw)[source]
Horizon predictions with MODEL-PREDICTIVE Monte Carlo intervals —
mixle.inference.forecast().The intervals are the fitted model’s own predictive quantiles, not held-out-calibrated bands (STAT-P20-04);
forecast_priceis the recalibrated route.
- do(interventions, **kw)[source]
Graph-surgery intervention —
mixle.inference.do()(M0’s generic engine: dependency trees, Bayesian networks, composites, mixtures; reduces tomixle.inference.bn_do()’s exact behavior for a fittedHeterogeneousBayesianNetwork).
- propose(data, *, fit=False, llm=None, holdout=0.25, seed=0, max_its=25, max_candidates=None, timeout=None, **recommend_kw)[source]
Propose a model for
datafrom a verified frontier of candidates and return the winner.The winner comes back UNFITTED unless ``fit=True`` is passed. Candidate ranking fits every candidate internally (on the training split, scored held-out), but what returns by default is the winning specification:
Model.fittedisNoneand the scoring verbs raise untilfit(data)runs.propose(data, fit=True)is the one-call propose-and-fit form.datais anything row-shaped: a list of records, a numpy array, a pandasDataFrame(one record per row across its columns – never a model of the column names), a mapping of equal-length columns, aDataSource, or a one-shot iterator (materialized once).Two paths return an unverified winner, and both are honestly disclosed (never silently).
max_candidates=0ortimeout=0.0skips every candidate: the winner falls back to the raw heuristic recommendation, with a"search budget: skipped ..."line inModel.notesand a"skipped": "search budget reached (...)"entry per candidate inModel.frontier, both readable throughexplain(). And when every candidate fails to verify (scoring errors, or every fit rejected as degenerate), the same fallback applies with a"no candidate could be verified: ..."note naming each failure. On BOTH routes, withfit=Truetheevidence["certificate"]record is downgraded fromsucceededtoattempted– a certificate produced for a winner nothing verified out-of-sample is not verification, whether the candidates failed or the budget skipped them. Any othermax_candidates/timeoutstill verifies every candidate it has budget for before choosing a winner.The verified frontier is guarded against degenerate wins: a candidate whose held-out score is a likelihood spike – a continuous fit whose scale collapsed onto a repeated value, detected by a positive log-density spike (max over held-out rows above 0, whether localized or median-wide, per the median) combined with pathological PIT calibration (total-variation error >= 1.0 of the 1.8 maximum) – is rejected from the frontier with a note, and the win falls to the best non-degenerate candidate. The criterion is deterministic and never fires on a model without a scalar predictive CDF (see
_degenerate_likelihood_spike).A joint (multi-field) candidate’s held-out score is also guarded against one field wrongly voiding every other, well-behaved field: when a field legitimately meets a held-out label its training split never saw,
CategoricalDistribution’s documenteddefault_value=0.0scores that row at-inf(an identifier-like/high-cardinality field with a long tail is the common real-world trigger). That field’s contribution is excluded from the candidate’s held-out score – not the whole candidate – with a note naming the field and how many held-out rows it affected (see_unseen_label_rescue); an unexplained non-finite score still fails the candidate outright.For scalar data the candidate universe follows the ELEMENT TYPE, and the choice is recorded in
Model.notes: integer-typed values are modeled by discrete families (categorical / Poisson / binomial …), float-typed values by continuous densities – the two universes score in different units (log-mass vs log-density) and cannot be ranked against each other, so passing.astype(float)on a count column is an affirmative statement that it is continuous.Candidates come from every proposer the library has — the heuristic recommendation (
mixle.task.recommend.recommend_model(), dependency-aware in the narrow sense of a joint multivariate-Gaussian candidate for fully-observed numeric vector rows), a structured-search candidate (mixle.inference.optimize()called with no pre-built estimator — its own no-estimator auto-structure-search, the fuller copula/learned-Bayesian-network dependence upgrade described in The Automatic-Modeling Contract’s “Dependence between fields”), the plain independence baseline (mixle.utils.automatic.get_estimator()), and, when anllmhandle is given, an LLM-designed structure (mixle.task.design.design_model(), allowlisted-spec, fit-validated). Each candidate is generated from the training split only, fitted on it, and scored on held-out data – the outer holdout is invisible to every proposer until final ranking (STAT-RR18-01), so the ranking is out-of-sample, not a guess. The winner becomes the returnedModel; the full ranking lands inModel.frontierand the per-field confidence / dependency / candidate notes inModel.notes(shown byexplain()). Passfit=Trueto also fit the winner to all ofdatabefore returning.The frontier search is bounded by
max_candidates(evaluate at most this many candidates, in proposer order — the heuristic recommendation is always first) andtimeout(stop starting new candidate fits once this many wall-clock seconds have elapsed). Both default toNone(unbounded). A candidate skipped for budget is recorded inModel.frontierandModel.notes, never silently dropped, so a bounded search reports exactly what it did not evaluate.