Skip to main content

ScoreQuant

Information-optimal hard compression for parametric inference.

ScoreQuant replaces continuous event-level information with a small number of hard bins while preserving as much Fisher information as possible for the parameters you actually want to measure.

CI Documentation

Many statistical workflows eventually reduce rich observations to counts in a few named categories: template and component fits, gated cell populations, binned likelihoods, trigger tiers, tables a collaborator can reproduce by hand. That compression is usually chosen for convenience — equal width, equal population, a threshold on one classifier output — even though the goal is parameter estimation, and it silently discards sensitivity the experiment already paid for.

ScoreQuant chooses the categories from the inference problem instead. For a regular parametric model $X\sim p(x\mid\theta)$, the local information an event carries at a reference point $\theta_0$ is summarized by its score

$$ s(x) = \nabla_\theta \log p(x\mid\theta)\big|_{\theta_0}. $$

ScoreQuant partitions score space into a few hard cells and optimizes the Fisher information retained by their counts. Binning is still lossy; the point is to make the loss a quantity you choose, measure and report rather than one you inherit from the axis ticks.

The model does not have to be a mixture. Mixture fractions, Gaussian means, calibration parameters, cross sections, rates, shape and nuisance parameters all enter through the same score representation.

Three independent choices

Using ScoreQuant means answering three separate questions:

  1. What are you optimizing? Labels for one finite sample, or a reusable rule for future events?
  2. How do you obtain the score? Already available, computed from an explicit model, or estimated through density ratios?
  3. Which parameter information matters? All of it through $D$-optimality, or only declared parameters of interest after profiling nuisance parameters through $D_s$?

The axes are independent.

                         SCORE ACCESS
                ┌──────────────────────────────┐
                │ precomputed scores           │
TASK            │ exact model / score oracle   │       OBJECTIVE
                │ density ratios / classifier  │
                └──────────────────────────────┘

sample partition ─────────────────────────────── D
space quantizer  ─────────────────────────────── Ds
                                                   normalized trace / baselines

The optimizer ultimately sees weighted score vectors. Everything before that is model access; everything after it is hard compression.

1. Choose the task

A. Sample partitioning — the best labels for the sample I have

partition = sq.optimize_partition(scores, n_bins=8, criterion=sq.DOptimality())

This is transductive. The decision variables are the labels of the supplied rows, and the result is a PartitionResult. A labeling of one finite table does not by itself say what should happen to an event you have not seen, so PartitionResult deliberately has no generic predict method.

There is one sanctioned crossing, and it is a theorem rather than a convenience: an exchange-stable, nonsingular $D$-optimal partition already is a strict self-consistent Voronoi partition in the $I_B^{-1}$-Mahalanobis metric. Such a result compiles into exactly that rule, and refuses when it is unstable or degenerate.

rule = partition.compile_quantizer()

An arbitrary finite $D_s$-optimal partition should not be assumed to define a reusable Voronoi quantizer.

B. Space quantization — the rule I apply to future events

fit = sq.fit_quantizer(source, provider=provider, n_bins=8, criterion=sq.DOptimality())
bins = fit.quantizer.predict_scores(provider.score(future_events))

This is the inductive problem. Use it when the bins themselves are the deliverable: a histogram definition, an event categorizer, a gating rule, or any analysis that must process observations that were not present during optimization.

2. Choose how the score is obtained

The common interface is $x \mapsto s(x)$, and there are three routes to it.

Score access You already have Interface
Precomputed scores score vectors $s_i$ ScoreSample
Exact model / score oracle a likelihood, component model, analytic or autodiff score ScoreFunction, LinearComponentScore
Density ratios analytic ratios, a direct ratio estimator, a calibrated classifier DensityRatioScore, CentralLogRatioScore

These are alternative upstream routes to the same downstream optimization problem. Absolute densities are never required: the score is the gradient of a log density ratio, so a ratio oracle is enough — but it must be a calibrated one. A ranking score or an arbitrary monotonic classifier output is not, since the construction needs ratios rather than event ordering. See Three doors for the derivation.

Sources and providers are different things

Fitting a reusable rule needs both a reference measure — which observations occur, with what weight — and a score map.

Source Meaning
ScoreSample(scores, weights) a finite weighted sample already in score space
ObservationSample(X, weights) finite weighted observations
IntegrationSource(bounds, density=...) deterministic quadrature over a bounded model

Observation-space sources require a provider; a ScoreSample rejects one. Model density ratios construct scores and enter through providers; importance ratios modify the reference measure and enter as source weights. The two never share an argument.

ScoreProvider is a public protocol, so an external estimator is a provider without being wrapped:

class MyExternalScore:
    provenance = sq.ScoreProvenance(kind="estimated_ratio")

    def score(self, observations):
        return my_package.evaluate(observations)

3. Choose what information to preserve

For a hard rule with cells $b=1,\ldots,K$, let $W_b=P(q(S)=b)$ and $\mu_b=E[S\mid q(S)=b]$. The information retained by the bin label is $I_B=\sum_b W_b,\mu_b\mu_b^\top$.

  • DOptimality() maximizes $\log\det I_B$, treating all score directions symmetrically. Use it when the complete parameter vector matters.
  • ProfiledDOptimality(interest=...) maximizes the log determinant of the Schur complement after profiling nuisance parameters. This is not a generally better $D$; it answers a different question, and can deliberately sacrifice large amounts of nuisance information to answer it.
  • NormalizedTrace() maximizes the Fisher-normalized retained trace. After whitening this is weighted $k$-means — an interpretable alternative and a baseline.

Parameters of interest can be named rather than indexed, which matters as soon as a model has more than a handful of components:

sample = sq.ScoreSample(
    scores, weights, schema=sq.ScoreSchema(("T", "B", "monocyte", "mast", "HSPC"))
)
criterion = sq.ProfiledDOptimality(interest=("HSPC",))

Reports then say interest: HSPC and nuisance: T, B, monocyte, mast.

Whichever route supplies the scores, optimize_partition always takes score rows — so routes 2 and 3 reach it through an explicit provider.score(X). The observation-to-score transformation never hides inside a fitting call, and prediction never silently recomputes scores.

Quick start

A Gaussian location model $x\sim\mathcal N(\mu, I_2)$ has $s(x)=x-\mu_0$, so at $\mu_0=0$ the observations are the score vectors.

import numpy as np
import scorequant as sq

rng = np.random.default_rng(7)
sample = sq.ScoreSample(
    rng.normal(size=(4_000, 2)),
    schema=sq.ScoreSchema(("mu_x", "mu_y")),
    provenance=sq.ScoreProvenance(kind="exact", reference_point=(0.0, 0.0)),
)

Partition this finite sample:

partition = sq.optimize_partition(
    sample, n_bins=6, criterion=sq.DOptimality(), config=sq.DExchangeConfig(seed=7)
)
assert partition.exchange_stable
print(round(float(partition.train_report.geometric_mean_retention), 3))

Six bins retain a $D$-efficiency of about 0.75 — the geometric mean of the retained-information eigenvalues. partition.labels belongs to these rows and nowhere else.

Fit a reusable rule instead, and deploy it:

fit = sq.fit_quantizer(
    sample, n_bins=6, criterion=sq.DOptimality(), config=sq.DExchangeConfig(seed=7)
)
assert fit.information_kind == "exact_fisher"

rule = fit.quantizer
future_bins = rule.predict_scores(rng.normal(loc=0.25, size=(1_000, 2)))

rule is the deployable object: a transform, centers and a metric, with no training data attached. It saves to a versioned, non-pickle artifact that loads and predicts in a process with no JAX installed — fit on the accelerated backend, deploy anywhere.

rule.save("gaussian-6bins.sqz")
rule = sq.Quantizer.load("gaussian-6bins.sqz")

Real-data showcase: FlowCyt

The FlowCyt study is the main end-to-end real-data example. Flow cytometry produces individual cells described by twelve marker measurements, while the scientific result is a vector of population fractions. The study uses all 30 patients: 20 reference, 10 frozen held-out, 600,000 sampled real cells drawn from 21,254,866 upstream events.

12-dimensional cell measurements → calibrated classifier → density ratios
    → 5-dimensional mixture score → ScoreQuant → 8 frozen hard bins
    → integer bin counts → downstream mixture fit → cell-population fractions

At the eight-bin operating point the learned quantizer retains 98.5% of the supplied-score surrogate information and reaches a 0.00193 macro RMSE on the ten held-out patients; the selected unbinned classifier-ratio baseline reaches 0.00173.

The study is useful beyond cytometry because it draws the boundaries explicitly: the classifier is not ScoreQuant, and the downstream mixture fitter is not ScoreQuant. ScoreQuant is the information-preserving hard-compression layer between them.

It also contains a real profiled-$D_s$ experiment, treating one cell fraction as the parameter of interest. Interestingly, $D_s$ does not materially improve the final measurement there: plain $D$ already lies close to a certified efficient-score ceiling. That is a useful negative result, and it illustrates why $D_s$ is a different inferential objective rather than an automatically superior one.

Solvers

Unsupported task/criterion combinations are rejected before any optimization runs.

Configuration optimize_partition fit_quantizer Contract
DExchangeConfig DOptimality, ProfiledDOptimality DOptimality Exact positive-gain relocations; monotone objective; terminates exchange-stable
MahalanobisLloydConfig DOptimality, ProfiledDOptimality DOptimality A batch is adopted only if the exactly rebuilt objective improves; optional exact-exchange guard
SoftVoronoiConfig DOptimality, ProfiledDOptimality Differentiable soft optimization then hardening, with the hardening gap reported
KMeansConfig NormalizedTrace Weighted $k$-means in whitened score space
ScalarDPConfig DOptimality The exact global interval solution for rank-one score space

The strong finite-sample bridge exchange stable $\Rightarrow I_B^{-1}$-Voronoi is specific to full $D$-optimality and should not be assumed for profiled $D_s$.

Certificates and diagnostics

Certificates are explicit operations; none runs silently during fitting. exchange_stability_report scans any supplied labeling exactly and reports the best remaining gain; certify_partition gives a branch-and-bound global certificate for full $D$, or an explicit outstanding gap when its budget runs out; efficient_score_bound gives a certified ceiling on profiled information for one parameter of interest; and PartitionResult.geometry measures the Voronoi violation a result leaves unclaimed. Validation data is diagnostic only — it never touches gradients, stopping, or checkpoint selection.

Score provenance

There is a difference between optimizing supplied vectors exactly and claiming those vectors are the exact statistical score. ScoreQuant records which it has: exact or autodiff provenance lets a result report information_kind == "exact_fisher", while classifier- or ratio-derived scores produce "supplied_score_surrogate". The optimization can be exact even when the vectors are estimates, and the distinction matters when reading a retained-information number.

Install

uv add scorequant

or, outside a uv project, pip install scorequant. To work on a checkout instead:

git clone https://github.com/VitalyVorobyev/scorequant.git
cd scorequant
uv sync --all-extras --all-groups

Python 3.12 or newer; JAX and Optax are the required numerical dependencies. ScoreQuant never sets global JAX configuration at import, so 64-bit precision is your application's call (JAX_ENABLE_X64=1). NumPy is a supported portable runtime, which is what lets a saved rule predict where JAX is absent.

Where ScoreQuant sits

data → likelihood, component model, ratio estimator or classifier → SCORE
     → [ ScoreQuant: score → hard label ] → counts → template fit / profile likelihood / report

ScoreQuant does not train the classifier and does not perform the final parameter fit. It answers one question well:

Given a limited number of hard categories, how should they be chosen so the downstream inference loses as little relevant information as possible?

How it relates to prior work

Choosing a quantizer to preserve Fisher information is established territory, and ScoreQuant does not claim to have invented it. Venkitasubramaniam, Tong and Swami introduced score-function quantizers for distributed estimation (CISS 2006); Farias and Brossier developed the scalar high-resolution theory of Fisher-optimal quantization (arXiv:1310.6945); Barnes, Han and Özgür characterized quantized Fisher information geometrically through conditional score means (Allerton 2018); Dülek proved convex-polytope optimality for sufficient-statistic quantizers under a trace criterion (IEEE TPAMI 2023). Determinant criteria for partitions date to Friedman and Rubin (1967) and Scott and Symons (1971), and D-optimality itself to Kiefer and Wolfowitz (1960). Inference-aware categorization is an active line of its own — INFERNO, ThickBrick, and the recent GATO/BOBR binning optimizers.

What ScoreQuant contributes is narrower and concrete: the exact finite-sample geometry of full-matrix D-optimal hard quantization. Relocating one weighted row is a rank-two update whose log-determinant gain is available in closed form, which makes the exchange monotone and its termination a stability certificate; exchange stability implies a strict self-consistent $I_B^{-1}$-Mahalanobis-Voronoi rule, which is what licenses compiling a finite partition into a reusable one; profiled $D_s$ comes with certified efficient-score upper bounds; and small instances can be closed with branch-and-bound global certificates. See Related work for the full map, including which pipeline stage each comparable package occupies.

Documentation

Why ScoreQuant · Method overview · Three doors · Choosing your workflow · The book · Examples · API guide and reference · FlowCyt study · Related work

The book develops the statistical theory independently of this package's API; the FlowCyt study is a reproducible end-to-end evaluation on a frozen patient split.

ScoreQuant is available under the MIT license.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

scorequant-0.1.0.tar.gz (98.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

scorequant-0.1.0-py3-none-any.whl (115.0 kB view details)

Uploaded Python 3

File details

Details for the file scorequant-0.1.0.tar.gz.

File metadata

  • Download URL: scorequant-0.1.0.tar.gz
  • Upload date:
  • Size: 98.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for scorequant-0.1.0.tar.gz
Algorithm Hash digest
SHA256 3baee3f7c9a056b12907ed886612690165afdc97ecbdd9692cca4085e91148f7
MD5 13c594e05ebd59ef837c742631f073ce
BLAKE2b-256 864a862595ec6a4445f6ac3da454a2a0ad0adcd67d746abc8cc3303bf09e15ff

See more details on using hashes here.

Provenance

The following attestation bundles were made for scorequant-0.1.0.tar.gz:

Publisher: release.yml on VitalyVorobyev/scorequant

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file scorequant-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: scorequant-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 115.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for scorequant-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 43f480f8cb5e49ae3690835e5d99afafb3ff9679cabb680805133f2d71fe6732
MD5 521f02559fb827426639e2e7a456597d
BLAKE2b-256 e8dbf394562f800dbfe51c9136b00493a3ed4779b73dd8cdc92a97df02be2886

See more details on using hashes here.

Provenance

The following attestation bundles were made for scorequant-0.1.0-py3-none-any.whl:

Publisher: release.yml on VitalyVorobyev/scorequant

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page