Skip to main content

itlog

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.

Quickstart

pip install itlog

Describe 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 three steps

Step 0 — Declaration (the data step)

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={...} collapses TRAIN_TT, SM_TT, CAR_TT into a single travel_time variable without a manual melt/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_id field 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.

Step 1 — Formulation (the model step)

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,
)

Step 2 — Execution (the estimation step)

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).


Estimation engine

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.

Model suites

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())

LaTeX export

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

Library comparison

Model & feature support

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.

Measured accuracy & speed

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.

Installation

python3.12 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"

Requires Python >= 3.10. Core dependencies: jax, jaxlib, numpy, pandas, scipy.

Examples

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/.

Testing

pytest -q                 # full suite with ≥95% coverage gate
pytest -q -m "not slow"   # fast unit tests only

Test layout mirrors the package: tests/expr/, tests/core/, tests/backends/, tests/distributions/, tests/models/.

Accuracy

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/.

Documentation

License

MIT — see LICENSE.

Release files for itlog 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for itlog 0.1.0
File Size Uploaded
itlog-0.1.0.tar.gz 45.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for itlog 0.1.0
File Interpreter ABI Platform
itlog-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 92.7 kB

Release files / itlog-0.1.0.tar.gz

Download URL itlog-0.1.0.tar.gz
Size 45.8 kB
Tags Source
SHA-256 checksum
How to use checksums
016f0d710395283861c73b92370f5e47d47bee2cccfdf43951a16b8dc29973db
BLAKE2b-256 checksum
How to use checksums
92c287c2eaeacb9e23b613d120d7e67893a3779ca80b5b2b8cd62f930629ff3a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jun 26, 2026.

Transparency log

Release files / itlog-0.1.0-py3-none-any.whl

Download URL itlog-0.1.0-py3-none-any.whl
Size 46.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7880a7abba0752ae99c5a4cef3c3ebcc46fb306f5ad00faccd1f8384d52cbb25
BLAKE2b-256 checksum
How to use checksums
c390742ace85f05674d4b73d7c8670bfc98b65f49fe28d9df1c9f2a1a9499bec
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.12

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jun 26, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release 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