Next-generation Python discrete choice modeling with a type-safe, declarative API and a JAX/XLA backend that runs unchanged on CPU or GPU.
itlog separates what your data is from what your model is from how it is estimated. That separation is the whole design, and it maps onto three steps:
| Step | Name | You write | itlog gives you |
|---|---|---|---|
| 0 | Declaration | A ChoiceDataset schema class |
A validated, canonical long-format tensor representation |
| 1 | Formulation | Utility equations with Parameter / Var |
A model checked against your data before any optimization |
| 2 | Execution | model.fit(dataset, ...) |
Estimates, standard errors, fit metrics, LaTeX |
Supported model families (v0.1.0):
- Multinomial Logit (MNL) — fixed coefficients.
- Nested Logit (NL) — GEV nests with alternative-specific nest scales.
- Cross-Nested Logit (CNL) — allocations across overlapping nests.
- Mixed Logit (MXL) — random coefficients (Normal, LogNormal, Triangular, Uniform, TruncatedNormal), optional correlation, Halton or pseudo-random draws.
- Latent Class (LC) — class-specific MNL with softmax membership.
- Ordered Logit / Probit — single-index ordinal models with monotone thresholds.
pip install itlogDescribe the data once, write utilities as plain Python expressions, and call
fit — the whole flow is a handful of lines:
import pandas as pd
import itlog as it
# 1. Declare the data (a small inline schema is all you need to get going)
class Travel(it.ChoiceDataset):
choice = it.Field(index=True, mapping={1: "train", 2: "car"})
travel_time = it.Field(sources={"train": "train_time", "car": "car_time"})
travel_cost = it.Field(sources={"train": "train_cost", "car": "car_cost"})
df = pd.DataFrame({
"choice": [1, 2, 1, 2],
"train_time": [40, 41, 35, 50], "car_time": [30, 25, 40, 35],
"train_cost": [3.0, 3.2, 2.8, 3.5], "car_cost": [5.0, 4.0, 6.0, 4.5],
})
data = Travel.from_pandas(df)
# 2. Formulate utilities
b_time, b_cost, asc_train = it.Parameter("b_time"), it.Parameter("b_cost"), it.Parameter("asc_train")
V = b_time * it.Var("travel_time") + b_cost * it.Var("travel_cost")
model = it.MultinomialLogit(utilities={"train": asc_train + V, "car": V})
# 3. Estimate
result = model.fit(data)
print(result.summary())Swap MultinomialLogit for NestedLogit, MixedLogit, LatentClass,
OrderedLogit, or any other family — the three-step flow below is identical.
The declarative schema is optional. For a quick fit you can keep the schema tiny — just point
Fields at your columns, as in the Quickstart, and you are done. The full machinery below (row filters, derived variables, availability masks, panel ids) pays off for complex, real-world datasets and for long-term model development, where declaring the data once keeps every downstream model consistent and reproducible. If you just need something quick, you do not need any of it.
Real choice data is messy: it arrives wide (one row per choice situation, alternative-specific attributes spread across columns) or long (one row per alternative), with availability flags, alternative-specific variables, free tickets, panel/repeated-choice structure, and filtering rules buried in the raw file. Step 0 is where you tame that once, declaratively, instead of threading reshape logic through the rest of your analysis.
You declare the dataset as a class. Each Field describes how a raw column
(or set of columns) maps into itlog's canonical long-format tensors. itlog
handles the wide→long reshape, builds the availability matrix, indexes the
choice and panel structure, and validates everything on ingestion.
Why a declarative schema (use cases):
- Wide datasets with alternative-specific columns —
sources={...}collapsesTRAIN_TT,SM_TT,CAR_TTinto a singletravel_timevariable without a manualmelt/pivot. - Complex, real-world data — row filtering (
filter_rows), derived variables, availability masks, and free-ticket / scaling logic live in one declared place and are reused across every model you fit on that data. - Panel / repeated-choice data — a single
panel_idfield tells itlog which rows share random draws in Mixed Logit; the simulator groups them for you. - Reproducibility & validation — the same schema yields the same canonical tensors every time, and the model is validated against the schema before estimation, so typos and shape mismatches fail fast with a clear message.
Wide format:
import pandas as pd
import itlog as it
class SwissmetroWide(it.ChoiceDataset):
choice = it.Field(index=True, mapping={1: "train", 2: "sm", 3: "car"})
travel_time = it.Field(sources={"train": "TRAIN_TT", "sm": "SM_TT", "car": "CAR_TT"})
travel_cost = it.Field(sources={"train": "TRAIN_CO", "sm": "SM_CO", "car": "CAR_CO"})
availability = it.Field(
availability=True,
sources={"train": "TRAIN_AV", "sm": "SM_AV", "car": "CAR_AV"},
)
@classmethod
def filter_rows(cls, df):
return df[(df["PURPOSE"].isin([1, 3])) & (df["CHOICE"] != 0)].copy()
dataset = SwissmetroWide.from_pandas(pd.read_csv("swissmetro.dat", sep="\t"))Long format with a panel id (for Mixed Logit):
class ElectricityLong(it.ChoiceDataset):
observation_id = it.Field(session_id=True, source="chid")
alternative = it.Field(alternative_id=True, source="alt")
choice = it.Field(is_choice_indicator=True, source="choice")
panel_id = it.Field(panel_id=True, source="id") # rows sharing draws
pf = it.Field(source="pf")
cl = it.Field(source="cl")
dataset = ElectricityLong.from_csv("electricity_long.csv")See examples/declarative_schema.py for a
runnable tour that inspects the canonical tensors produced by each schema.
Utilities are written as ordinary Python expressions using Parameter and
Var, with operator overloading building the symbolic utility for each
alternative. Coefficients can be shared across alternatives (conditional logit)
or be alternative-specific. The model is validated against the dataset's
feature names and alternatives before any optimization runs.
b_time = it.Parameter("b_time")
b_cost = it.Parameter("b_cost")
asc_sm = it.Parameter("asc_sm")
asc_car = it.Parameter("asc_car")
model = it.MultinomialLogit(utilities={
"train": b_time * it.Var("travel_time") + b_cost * it.Var("travel_cost"),
"sm": b_time * it.Var("travel_time") + b_cost * it.Var("travel_cost") + asc_sm,
"car": b_time * it.Var("travel_time") + b_cost * it.Var("travel_cost") + asc_car,
})For Mixed Logit, declare a coefficient as random by giving it a distribution; itlog estimates its mean and standard deviation by simulated maximum likelihood:
RANDOM = ["pf", "cl", "loc", "wk", "tod", "seas"]
params = {name: it.Parameter(name, distribution="Normal") for name in RANDOM}
utility = sum(params[name] * it.Var(name) for name in RANDOM)
model = it.MixedLogit(utilities={alt: utility for alt in (1, 2, 3, 4)})Nested Logit — nest membership and nest scales:
model = it.NestedLogit(
utilities=V,
nests={
"public": it.Nest(["train", "sm"], scale=it.Parameter("mu_public")),
"private": it.Nest(["car"]),
},
)Cross-Nested Logit — allocation weights per alternative×nest:
model = it.CrossNestedLogit(
utilities=V,
nests={"public": it.Nest(scale=it.Parameter("mu_pub")), "fast": it.Nest(scale=it.Parameter("mu_fast"))},
allocations={"train": {"public": it.Parameter("a_train_pub"), "fast": 1.0}, "sm": {"public": 1.0}, "car": {"fast": 1.0}},
)Latent Class — per-class utilities and membership logits:
model = it.LatentClass(
classes=[utilities_class1, utilities_class2],
membership=it.ClassMembership({"class2": it.Parameter("g0") + it.Parameter("g_inc") * it.Var("income")}),
)Ordered Logit — ordinal outcome on the dataset schema:
model = it.OrderedLogit(
utility=it.Parameter("b_age") * it.Var("age") + it.Parameter("b_inc") * it.Var("income"),
outcome="rating",
n_categories=5,
)model.fit(...) estimates the parameters and returns a result object with the
log-likelihood, standard errors, and fit metrics. The same call runs on CPU or
GPU via the engine argument — the model and data declarations are unchanged.
results = model.fit(dataset, method="BFGS") # CPU (default)
# results = model.fit(dataset, method="BFGS", engine="gpu") # GPU, if available
print(results.summary())
print(f"AIC={results.aic:.2f}, BIC={results.bic:.2f}")Mixed Logit takes the number of simulation draws and a seed:
import numpy as np
results = model.fit(dataset, method="BFGS", init=np.zeros(12), num_draws=600, seed=123)Pass compute_se=False to skip the standard-error (Hessian) computation when you
only need point estimates (e.g. inside timing loops).
engine |
Backend | Notes |
|---|---|---|
cpu (default) |
NumPy analytic kernels | Differenced-utility MNL and simulated MXL with closed-form gradients |
gpu |
JAX / XLA JIT | Same model, same data; offloads the hot kernels to the GPU |
Both paths run in double precision for estimator-grade accuracy. The CPU
kernels use the xlogit-style differenced-utility formulation,
P = 1 / (1 + Σ exp(Vd)), with analytic gradients; the GPU path uses JAX
jit/grad on the same objective. GPU is the intended path for large
simulated Mixed Logit workloads.
Fit and compare multiple specifications on one dataset, then rank them by any fit metric:
suite = it.ModelSuite("swissmetro-mnl")
suite.add("base", model)
suite.add("extended", extended_model)
report = suite.fit(dataset, method="BFGS", n_jobs="auto")
print(report.compare()) # log-likelihood, AIC, BIC, rho-squared per model
print(report.rank(by="bic")) # ranked comparison table
print(report["base"].to_dataframe())Every model and result can emit publication-ready LaTeX — utility equations, parameter tables, and full suite comparisons:
print(model.to_latex(labels={"b_time": r"\beta_{\text{time}}"})) # aligned utility equations
print(result.to_latex()) # parameter table with std errors
print(report.to_latex(include_parameters=True)) # comparison + per-model appendix| Capability | itlog | pylogit | xlogit | biogeme | apollo |
|---|---|---|---|---|---|
| MNL / conditional logit | ✓ | ✓ | ✓ | ✓ | ✓ |
| Nested logit | ✓ | ✓ | — | ✓ | ✓ |
| Cross-nested logit | ✓ | — | — | ✓ | ✓ |
| Mixed logit | ✓ | normal only | ✓ | ✓ | ✓ |
| Mixing distributions (n/ln/t/u/tn) | ✓ | n only | ✓ | ✓ | ✓ |
| Correlated random coefficients | ✓ | — | — | ✓ | ✓ |
| Latent class | ✓ | — | — | ✓ | ✓ |
| Ordered logit / probit | ✓ | — | — | ✓ | ✓ |
| Asymmetric closed-form models (scobit, clog-log, uneven) | — | ✓ | — | — | — |
| Halton / quasi-random draws | ✓ | ✓ | ✓ | ✓ | ✓ |
| Panel / mixing id | ✓ | ✓ | ✓ | ✓ | ✓ |
| Declarative data schema | ✓ | — | — | — | — |
| GPU acceleration | ✓ (JAX/XLA) | — | ✓ (CuPy) | — | — |
| Python-native API | ✓ | ✓ | ✓ | ✓ | R |
Notes:
- pylogit is broader than mixed logit alone: besides MNL, nested, and mixed logit (Normal mixing only), it uniquely offers a family of asymmetric, closed-form choice models (scobit, clog-log, uneven, asymmetric logit) that itlog does not implement.
- xlogit is built for GPU-accelerated mixed logit (enable by installing CuPy); it covers MNL and mixed logit with the full distribution set but does not do nested, cross-nested, latent class, ordered, or correlated random parameters.
- itlog roadmap (documented, not yet implemented): ICLV/hybrid, MDCEV, multinomial probit, exploded logit, and pylogit-style asymmetric utilities.
Live three-way run on an M4 MacBook Air (CPU), identical specifications, 600 MXL
draws. itlog and xlogit time the point-estimate path (compute_se=False /
skip_std_errs); pylogit computes standard errors by default. Full table and
methodology in docs/benchmark-results.md;
reproduce with the scripts in benchmarks/.
Speed (warm median, seconds — lower is better):
| Benchmark | itlog | pylogit | xlogit |
|---|---|---|---|
| Swissmetro 4p MNL | 0.01 | 0.10 | 0.005 |
| Swissmetro 14p MNL | 0.02 | 0.11 | 0.006 |
| Electricity MXL (600 draws) | 6.3 | 38.0 | 4.1 |
itlog is ~8–10× faster than pylogit on MNL and ~6× faster on the MXL, and lands within ~1.3–1.5× of xlogit's CPU kernels — before moving to GPU.
Accuracy (final log-likelihood):
| Benchmark | itlog | pylogit | xlogit |
|---|---|---|---|
| Swissmetro 4p MNL | -5331.2520 | -5331.2520 | -5331.2520 |
| Swissmetro 14p MNL | -5159.2583 | -5159.2583 | -5564.1788† |
| Electricity MXL (600 draws) | -3887.92 | -3910.32 | -3901.19 |
itlog matches pylogit's MNL log-likelihoods to ~1e-5. †xlogit uses a different
intercept parameterization on the 14p spec. MXL log-likelihoods differ across
all three because each library draws from a different random-number generator
(simulation variance, which shrinks as num_draws grows), not estimation error.
python3.12 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"Requires Python >= 3.10. Core dependencies: jax, jaxlib, numpy, pandas,
scipy.
Runnable scripts under examples/ (run from the repo root, e.g.
python examples/swissmetro_mnl_wide.py):
| Script | Step(s) shown |
|---|---|
examples/declarative_schema.py |
0 — wide & long schemas and the canonical tensors |
examples/swissmetro_mnl_wide.py |
0→2 — wide-format MNL on Swissmetro |
examples/electricity_mxl_long.py |
0→2 — long-format Mixed Logit on Electricity panel data |
examples/model_suite_comparison.py |
Fit and rank multiple specifications |
examples/latex_export.py |
Export equations and tables to LaTeX |
examples/gpu_fit.py |
Fit with engine="gpu", with graceful CPU fallback |
examples/benchmarks_cpu_gpu.py |
CPU/GPU cold vs warm timing |
Cross-library benchmark scripts (itlog vs pylogit/xlogit/biogeme) live in
benchmarks/.
pytest -q # full suite with ≥95% coverage gate
pytest -q -m "not slow" # fast unit tests onlyTest layout mirrors the package: tests/expr/, tests/core/,
tests/backends/, tests/distributions/, tests/models/.
itlog reproduces the published Swissmetro MNL log-likelihoods exactly to four
decimals (4-parameter: -5331.2520; 14-parameter: -5159.2583), and the
simulated Mixed Logit kernel agrees with an independent reference and with JAX
autodiff to machine precision. Correctness is pinned by
tests/backends/test_numpy_kernels.py:
differenced-utility vs full-softmax equivalence, analytic gradients/Hessian vs
finite differences, and the NumPy CPU path vs the JAX path. New model families
(NL, CNL, LC, Ordered) use JAX autodiff kernels validated in
tests/backends/test_likelihoods.py and
degenerate-equivalence tests under tests/models/.
docs/feature-map.md— data schemas, model suites, LaTeX export.docs/benchmarks.md— validation methodology and parity discussion.docs/benchmark-results.md— measured speed and accuracy (M4 CPU).
MIT — see LICENSE.