Get started
ScoreQuant turns continuous score vectors into a small number of hard labels while retaining as much Fisher information as possible.
You provide one score vector s(x) for each observation. ScoreQuant finds the bins.
Install
uv add scorequant
or:
pip install scorequant
Python 3.12 or newer.
Fit a quantizer
Start with a small example. For a Gaussian location model with identity covariance, the score at the origin is the observation itself, s(x) = x, so we can generate score vectors directly. Everything after this point is the same whatever produced your scores.
One choice is worth making explicitly rather than inheriting. ScoreQuant runs on two backends: JAX is the default and the faster one, NumPy is the portable one, and whether to enable 64-bit floats is the application's call and never the library's. Every cell below passes an explicit NumPy, float64 execution, so the output printed here is the output you get. The cells share one namespace and run top to bottom, and together they are a single file you can run yourself.
import numpy as np
import scorequant as sq
rng = np.random.default_rng(21)
execution = sq.ExecutionConfig(backend="numpy", precision="float64", device="cpu")
# N(mu, I_2) at mu0 = 0 has score s(x) = x: these raw observations already
# are the score vectors every later cell works with.
scores = rng.normal(size=(1_200, 2))
weights = np.ones(scores.shape[0])Now replace those continuous two-dimensional scores with five discrete labels:
quantizer = sq.fit_quantizer(
sq.ScoreSample(scores, weights),
n_bins=5,
criterion=sq.DOptimality(),
config=sq.SoftVoronoiConfig(seed=21, initializer_restarts=4, max_steps=120, record_every=20),
execution=execution,
)
report = quantizer.train_report
print(f"D-efficiency {report.geometric_mean_retention:.4f}")
print(f"effective rank {report.effective_rank}")D-efficiency 0.6987
effective rank 2
That is the main result.
The original observations carry a two-dimensional Fisher information matrix. After replacing each observation by one of only five labels, the geometric mean information retention — the D-efficiency — is what the fit reports. It is a ratio rather than a score, which is what makes it comparable across bin budgets.
The effective rank is the number to read against your parameter count. Information is a matrix, and a partition can retain a great deal in one direction while collapsing another. A rank below the number of parameters means a direction is gone, and no averaged headline will tell you that.
Use the rule on new observations
fit_quantizer returns a reusable rule in score space:
new_scores = rng.normal(loc=0.2, size=(300, 2))
labels = quantizer.predict_scores(new_scores)
counts = ", ".join(f"{count}" for count in np.bincount(labels, minlength=5))
print(f"bin counts [{counts}]")bin counts [38, 86, 52, 68, 56]
Each new score vector is now mapped to one of the same five bins.
ScoreQuant deliberately predicts from scores, not raw observations. How you obtain those scores — from an analytic likelihood, simulation, density ratios, or a classifier — is a separate part of the problem, and the conversion is never hidden inside prediction.
What did we optimize?
If the original score is s(x), observing it retains the full Fisher information available in that score. After quantization we keep only the bin label, one of K values. ScoreQuant chooses the rule so that these few discrete outcomes retain as much of the original information as possible.
Here we used DOptimality(): it rewards partitions that retain information jointly across all parameter directions. geometric_mean_retention reports the resulting D-efficiency as a dimensionless ratio.
That is the basic ScoreQuant workflow, and the reported retention is part of the output rather than an afterthought:
Only need labels for this sample?
Sometimes there will be no future observations. You only want the best partition of a finite table already in memory.
Use optimize_partition instead:
partition = sq.optimize_partition(
scores,
weights=weights,
n_bins=5,
criterion=sq.DOptimality(),
config=sq.DExchangeConfig(seed=21),
execution=execution,
)
print(f"D-efficiency {partition.train_report.geometric_mean_retention:.4f}")D-efficiency 0.6988
partition.labels contains the optimized labels for these rows.
Unlike fit_quantizer, this operation does not in general define how an unseen score vector should be labeled. A finite-sample optimum names labels, not a rule: many different rules reproduce the same labels on one sample and disagree everywhere else, which is why PartitionResult has no predict method. If you need to label future observations, fit a quantizer.
This finite fit is exact and deterministic, which is why it is the one you can re-run here, in your own browser, against the same table and the same seed:
Fetches the same 1,200 rows this page fits, then runs the same D-optimal exchange solver on the NumPy backend inside your browser.
Where next?
For complete applied examples, go to Walkthroughs. They start from an estimation problem and show where the scores come from, how the bins are fitted, and what the resulting information loss means.
For solver options, criteria, diagnostics, weights, nuisance parameters, persistence, and the exact API, use the reference documentation.
For the mathematics behind the optimization and its guarantees, see Research.