User Guide¶
FreqProb turns counts into probabilities. You give it a mapping of elements to how often you observed them; it gives you back a smoothed probability estimator that also assigns sensible probability to things you haven't seen.
That problem shows up far beyond text: species you didn't catch in a survey, k-mers absent from a sample, product categories with no clicks yet. This guide teaches the concepts, helps you pick a method, and works through examples in several domains. Every code block on this page is executed in the test suite, so the examples stay correct against the installed version.
New here? Read Core concepts and Choosing a method, then jump to the worked example closest to your problem.
Core concepts¶
One contract for every estimator¶
Every estimator is built from a frequency distribution and then called to score an element. That is the whole interface.
import freqprob
counts = {"the": 30, "cat": 6, "sat": 3, "mat": 1}
mle = freqprob.MLE(counts, logprob=False)
assert round(mle("the"), 2) == 0.75 # 30 of 40 observations
assert round(mle("cat"), 2) == 0.15 # 6 of 40
Swapping methods means swapping the constructor — the call site never changes.
The unseen-element problem¶
Plain relative frequency (MLE) gives anything unseen a probability of exactly
zero, which is usually wrong and breaks anything multiplicative (a single zero
zeroes the whole product). Smoothing moves a little mass onto the unseen.
import freqprob
counts = {"the": 30, "cat": 6, "sat": 3, "mat": 1}
mle = freqprob.MLE(counts, logprob=False)
laplace = freqprob.Laplace(counts, bins=1000, logprob=False)
assert mle("mouse") == 0.0 # MLE: unseen -> zero
assert laplace("mouse") > 0.0 # Laplace: unseen -> small but non-zero
bins is the size of the space of possible elements (here, an assumed
vocabulary of 1000). It tells additive methods how much unseen mass to reserve;
see Choosing bins.
Log space and logprob¶
By default estimators return log-probabilities (logprob=True), because
real data multiplies many small probabilities and linear space underflows to
zero. Pass logprob=False when you want plain probabilities for a quick look.
import math
import freqprob
counts = {"a": 3, "b": 1}
p = freqprob.MLE(counts, logprob=False)
logp = freqprob.MLE(counts, logprob=True)
assert math.isclose(math.exp(logp("a")), p("a"))
See Working in log space for why this matters and how to combine log-probabilities correctly.
Choosing a method¶
Start from what your data looks like:
| Your situation | Method | Key parameter |
|---|---|---|
| Just want relative frequencies, no smoothing | MLE |
— |
| General-purpose additive smoothing | Laplace / Lidstone / ELE |
bins, gamma |
| Heavy-tailed counts, many rare elements | SimpleGoodTuring |
p_value |
| Parameter-free discounting by distinct-type count | WittenBell |
bins |
| n-gram language models | KneserNey / ModifiedKneserNey |
discount |
| You have a prior belief (Dirichlet) | Bayesian |
alpha |
| Combine a specific and a general model | Interpolated |
lambda_weight |
| Reserve mass by how fully the support is observed (experimental) | CertaintyDegree |
bins |
| Non-informative baseline | Uniform / Random |
— |
By data characteristics:
| Vocabulary | Data density | Good default |
|---|---|---|
| Small | Dense (most elements seen often) | MLE or Laplace |
| Large | Sparse (many rare/unseen) | SimpleGoodTuring or Lidstone (small gamma) |
| Sequence contexts (n-grams) | Any | KneserNey |
| Any | You have a trusted background model | Interpolated |
When-to-use, in one line each:
MLE— a reference point; not for sparse data (zeros unseen events).Laplace(add-one) — simple and robust, but over-smooths large vocabularies.Lidstone/ELE— add a fractionalgamma;ELE(gamma=0.5) is a gentler default than add-one for large vocabularies.SimpleGoodTuring— the strongest general estimate of unseen mass for heavy-tailed data; needs a spread of low frequencies to fit.WittenBell— reserves mass in proportion to the number of distinct things seen; a solid, assumption-light choice.KneserNey— the standard for n-gram models; models how likely an element is to appear in a novel context.Bayesian— smoothing as a Dirichlet(alpha) prior; principled and tunable.Interpolated— mix a high-order (specific) and low-order (general) model.CertaintyDegree— reserves mass by how fully the possible support has been observed; the more of the space you have seen, the less is held back. Experimental — not recommended as your only estimator yet.
The methods¶
Each estimator below takes a freqdist and returns log-probabilities unless you
pass logprob=False. The examples use logprob=False for readability.
Baselines: MLE, Uniform, Random¶
MLE is relative frequency. Uniform ignores counts and splits mass evenly.
Random assigns reproducible pseudo-random probabilities — useful only as a
control.
import freqprob
counts = {"a": 6, "b": 3, "c": 1}
assert round(freqprob.MLE(counts, logprob=False)("a"), 2) == 0.6
assert round(freqprob.Uniform(counts, logprob=False)("a"), 4) == round(1 / 3, 4)
Additive smoothing: Laplace, Lidstone, ELE¶
Add a pseudo-count to every possible element. Laplace adds 1; Lidstone adds
a configurable gamma; ELE adds 0.5. Larger gamma and larger bins reserve
more mass for the unseen.
import freqprob
counts = {"a": 6, "b": 3, "c": 1}
laplace = freqprob.Laplace(counts, bins=100, logprob=False)
lidstone = freqprob.Lidstone(counts, gamma=0.1, bins=100, logprob=False)
assert laplace("z") > 0 # unseen gets mass
assert lidstone("z") < laplace("z") # smaller gamma -> less smoothing
Discounting for heavy tails: Simple Good-Turing, Witten-Bell¶
For data with many rare elements, these estimate how much total probability belongs to everything you didn't see. Good-Turing does it from the frequency-of-frequencies; Witten-Bell from the count of distinct elements.
import freqprob
# A Zipfian-ish distribution with several singletons.
counts = {"a": 10, "b": 5, "c": 4, "d": 3, "e": 2, "f": 2, "g": 1, "h": 1, "i": 1, "j": 1}
sgt = freqprob.SimpleGoodTuring(counts, logprob=False)
assert 0.0 < sgt.total_unseen_mass < 1.0 # reserved for unseen types
assert sgt("unseen") > 0.0
# Witten-Bell: parameter-free, reserves mass by the count of distinct types.
wb = freqprob.WittenBell(counts, logprob=False)
assert wb("a") > wb("g") # observed elements ranked by frequency
assert wb("unseen") > 0.0 # unseen types share the reserved mass
n-gram models: Kneser-Ney¶
The standard smoothing for language models. It discounts observed n-grams and redistributes mass using how many distinct contexts an element completes — capturing that "Francisco" is frequent but only after "San".
import freqprob
bigrams = {("the", "cat"): 5, ("the", "dog"): 3, ("a", "cat"): 2}
kn = freqprob.KneserNey(bigrams, discount=0.75, logprob=False)
assert 0.0 < kn(("the", "cat")) <= 1.0
assert kn(("the", "bird")) > 0.0 # unseen bigram still gets mass
Priors and interpolation: Bayesian, Interpolated¶
Bayesian treats smoothing as a Dirichlet(alpha) prior over categories.
Interpolated blends a high-order (specific) distribution with a low-order
(general) one via lambda_weight.
import freqprob
high = {("the", "big", "cat"): 1, ("a", "big", "dog"): 1}
low = {"cat": 3, "dog": 2, "the": 5}
interp = freqprob.Interpolated(high, low, lambda_weight=0.7, logprob=False)
assert 0.0 <= interp(("the", "big", "cat")) <= 1.0
Certainty-degree smoothing: CertaintyDegree¶
CertaintyDegree reserves mass for the unseen according to how much of the
possible support has already been observed: the more of the space you have seen,
the more certain it is that little remains unseen, so less mass is held back. The
remainder rescales the MLE of each observed element. Pass bins to declare the
size of the possible support.
import freqprob
counts = {"apple": 8, "banana": 4, "cherry": 2, "date": 1}
cd = freqprob.CertaintyDegree(counts, logprob=False)
assert cd("apple") > cd("date") # observed elements ranked by frequency
assert cd("kiwi") > 0.0 # unobserved element still gets mass
This is an experimental estimator still under development; it is documented for completeness but is not recommended as your sole or primary method yet.
Evaluating models¶
Score how well an estimator predicts held-out data. Lower perplexity and cross-entropy are better. Build the estimator in log space for these.
import freqprob
train = {"a": 8, "b": 4, "c": 2, "d": 1}
test = ["a", "b", "a", "c", "e"] # note: 'e' is unseen in training
laplace = freqprob.Laplace(train, bins=100, logprob=True)
pp = freqprob.perplexity(laplace, test)
xe = freqprob.cross_entropy(laplace, test)
assert round(pp, 1) == 27.8
assert round(xe, 1) == 3.3
Compare several models at once with model_comparison:
import freqprob
train = {"a": 8, "b": 4, "c": 2, "d": 1}
test = ["a", "b", "a", "c", "e"]
models = {
"laplace": freqprob.Laplace(train, bins=100, logprob=True),
"mle": freqprob.MLE(train, unobs_prob=0.01, logprob=True),
}
report = freqprob.model_comparison(models, test)
assert set(report) == {"laplace", "mle"}
Worked examples by domain¶
The same counts-to-probabilities tool fits many fields. Each example is self-contained.
Text and language¶
Word or n-gram frequencies with additive smoothing — the classic case.
import freqprob
text = "the cat sat on the mat the cat sat".split()
counts = freqprob.word_frequency(text)
model = freqprob.Laplace(counts, bins=10_000, logprob=False)
assert counts["the"] == 3
assert model("the") > model("mat") # more frequent -> higher probability
assert model("dog") > 0.0 # unseen word still scored
Build n-grams with the text helpers:
import freqprob
tokens = ["the", "cat", "sat"]
assert freqprob.generate_ngrams(tokens, 2) == [("the", "cat"), ("cat", "sat")]
Ecology: species abundance and unseen species¶
Good-Turing was invented for exactly this — estimating the probability mass of species you didn't observe in a survey (the "unseen species" problem).
import freqprob
# Individuals counted per species in a survey (many rare species).
survey = {
"sparrow": 40,
"robin": 18,
"finch": 12,
"hawk": 3,
"owl": 2,
"crane": 1,
"heron": 1,
"egret": 1,
}
sgt = freqprob.SimpleGoodTuring(survey, logprob=False)
# Probability mass reserved for as-yet-unseen species:
assert sgt.total_unseen_mass > 0.0
assert round(sgt("sparrow"), 2) == 0.50 # the dominant species
Genomics: k-mer frequencies¶
Count k-mers (length-k subsequences) in a sequence and smooth over the space of all possible k-mers, so absent k-mers get non-zero probability.
from collections import Counter
import freqprob
sequence = "ATGGCATGCA"
k = 3
kmers = Counter(sequence[i : i + k] for i in range(len(sequence) - k + 1))
# 4 nucleotides -> 4**3 = 64 possible 3-mers.
model = freqprob.Laplace(dict(kmers), bins=64, logprob=False)
assert kmers["ATG"] == 2
assert model("ATG") > model("TTT") # observed vs absent k-mer
assert model("TTT") > 0.0
Categorical features and analytics¶
Smoothed category priors keep a rare or brand-new category from collapsing to zero probability — useful for ML features, event/click counts, and A/B data.
import freqprob
category_counts = {"electronics": 120, "books": 80, "toys": 25, "garden": 5}
prior = freqprob.Laplace(category_counts, bins=10, logprob=False)
assert prior("electronics") > prior("garden")
assert prior("music") > 0.0 # a category with no events yet
Scaling and performance¶
The default estimators are in-memory and eager, which is fine for most work. For large or streaming data, FreqProb offers drop-in alternatives that share the same call contract. Reach for these only when you actually hit a limit.
Streaming updates¶
Update counts incrementally without rebuilding the model, for data that arrives over time or doesn't fit in memory.
import freqprob
model = freqprob.StreamingMLE(logprob=False)
model.update_batch(["x", "y", "x", "z"])
assert round(model("x"), 2) == 0.5 # 2 of 4 so far
model.update_single("x")
assert round(model("x"), 2) == 0.6 # 3 of 5 after one more
Vectorized batch scoring¶
Score many elements at once as a NumPy array — much faster than a Python loop.
import freqprob
scorer = freqprob.VectorizedScorer(freqprob.MLE({"a": 3, "b": 1}, logprob=False))
scores = scorer.score_batch(["a", "b", "c"])
assert [round(float(s), 2) for s in scores] == [0.75, 0.25, 0.0]
Lazy evaluation¶
Defer per-element computation until an element is actually scored — helpful when you only touch a small slice of a huge vocabulary.
import freqprob
lazy = freqprob.create_lazy_mle({"a": 3, "b": 1}, logprob=False)
assert round(lazy("a"), 2) == 0.75
Compact storage¶
create_compressed_distribution and create_sparse_distribution trade a little
speed for much lower memory on large, skewed count tables. MemoryProfiler
helps you measure the difference — see the API reference for details.
import freqprob
compact = freqprob.create_compressed_distribution({"a": 100, "b": 50, "c": 1})
assert compact.get_memory_usage()["total"] > 0
Practical notes¶
Working in log space¶
Multiplying many probabilities underflows; adding log-probabilities does not.
Keep logprob=True (the default) for any real scoring pipeline, and combine
scores with logsumexp when you need to sum probabilities in log space.
import math
# Product of many small probabilities underflows to 0.0 in linear space...
probs = [1e-30] * 20
assert math.prod(probs) == 0.0
# ...but the sum of their logs is finite and exact.
logs = [math.log(p) for p in probs]
assert math.isfinite(sum(logs))
To add probabilities that are stored as logs (e.g. mixing models), use a stable log-sum-exp rather than exponentiating first:
from scipy.special import logsumexp
log_a, log_b = -700.0, -701.0 # would underflow under exp()
combined = logsumexp([log_a, log_b]) # log(exp(log_a) + exp(log_b))
assert combined > log_a # the sum is larger than either term
Choosing bins¶
bins is the number of possible distinct elements (the support size),
including those you haven't observed. Additive methods spread reserved mass over
bins - observed. If you don't know it exactly, estimate a little above your
observed vocabulary — a rule of thumb is 20–50% more:
counts = {"a": 5, "b": 3, "c": 1}
estimated_bins = int(len(counts) * 1.3) # ~30% headroom for unseen types
assert estimated_bins >= len(counts)
Save and load a fitted model¶
Persist a fitted estimator and restore it without re-fitting (pickle-based; only load files you trust).
import os
import tempfile
import freqprob
model = freqprob.Laplace({"a": 5, "b": 3}, bins=100, logprob=False)
path = os.path.join(tempfile.mkdtemp(), "model.pkl")
model.save(path)
restored = freqprob.Laplace.load(path)
assert restored("a") == model("a")
scikit-learn-style API¶
Alongside the callable contract, every estimator offers fit / predict /
score. score is a single-element alias for calling the estimator; predict
scores an iterable in one call.
import freqprob
model = freqprob.MLE({"a": 3, "b": 1}, logprob=False)
assert model.score("a") == model("a")
assert list(model.predict(["a", "b"])) == [model("a"), model("b")]
Next steps¶
- API Reference — every public class and function, generated from the source, with a runnable example for each.
- Home — the one-minute overview and the method-selection table.