Skip to main content

Declarative, JAX-backed discrete choice modeling for Python (MNL, Nested, Cross-Nested, Mixed, Latent Class, Ordered)

Project description

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 columnssources={...} 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.

Project details


Download files

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

Source Distribution

itlog-0.1.0.tar.gz (45.8 kB view details)

Uploaded Source

Built Distribution

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

itlog-0.1.0-py3-none-any.whl (46.9 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: itlog-0.1.0.tar.gz
  • Upload date:
  • Size: 45.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for itlog-0.1.0.tar.gz
Algorithm Hash digest
SHA256 016f0d710395283861c73b92370f5e47d47bee2cccfdf43951a16b8dc29973db
MD5 ac6dfb6b44ebd77b80346fd5001b70df
BLAKE2b-256 92c287c2eaeacb9e23b613d120d7e67893a3779ca80b5b2b8cd62f930629ff3a

See more details on using hashes here.

Provenance

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

Publisher: publish.yml on cobylim/itlog

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

File details

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

File metadata

  • Download URL: itlog-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 46.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for itlog-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7880a7abba0752ae99c5a4cef3c3ebcc46fb306f5ad00faccd1f8384d52cbb25
MD5 a4526e65592de2b82f3320108d2a37b5
BLAKE2b-256 c390742ace85f05674d4b73d7c8670bfc98b65f49fe28d9df1c9f2a1a9499bec

See more details on using hashes here.

Provenance

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

Publisher: publish.yml on cobylim/itlog

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

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page