Metadata-Version: 2.4
Name: itlog
Version: 0.1.0
Summary: Declarative, JAX-backed discrete choice modeling for Python (MNL, Nested, Cross-Nested, Mixed, Latent Class, Ordered)
Author: itlog contributors
License-Expression: MIT
Project-URL: Homepage, https://github.com/cobylim/itlog
Project-URL: Repository, https://github.com/cobylim/itlog
Project-URL: Issues, https://github.com/cobylim/itlog/issues
Keywords: discrete-choice,logit,mixed-logit,nested-logit,latent-class,econometrics,choice-modeling,jax
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Scientific/Engineering :: Mathematics
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: jax>=0.4.20
Requires-Dist: jaxlib>=0.4.20
Requires-Dist: numpy>=1.24
Requires-Dist: pandas>=2.0
Requires-Dist: scipy>=1.11
Provides-Extra: dev
Requires-Dist: pytest>=7.4; extra == "dev"
Requires-Dist: pytest-cov>=4.1; extra == "dev"
Dynamic: license-file

# 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

```bash
pip install itlog
```

Describe the data once, write utilities as plain Python expressions, and call
`fit` — the whole flow is a handful of lines:

```python
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 `Field`s at your columns, as in the [Quickstart](#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:

```python
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):

```python
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`](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.

```python
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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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.

```python
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:

```python
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:

```python
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:

```python
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`](docs/benchmark-results.md);
reproduce with the scripts in [`benchmarks/`](benchmarks/README.md).

**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

```bash
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`](examples/declarative_schema.py) | **0** — wide & long schemas and the canonical tensors |
| [`examples/swissmetro_mnl_wide.py`](examples/swissmetro_mnl_wide.py) | 0→2 — wide-format MNL on Swissmetro |
| [`examples/electricity_mxl_long.py`](examples/electricity_mxl_long.py) | 0→2 — long-format Mixed Logit on Electricity panel data |
| [`examples/model_suite_comparison.py`](examples/model_suite_comparison.py) | Fit and rank multiple specifications |
| [`examples/latex_export.py`](examples/latex_export.py) | Export equations and tables to LaTeX |
| [`examples/gpu_fit.py`](examples/gpu_fit.py) | Fit with `engine="gpu"`, with graceful CPU fallback |
| [`examples/benchmarks_cpu_gpu.py`](examples/benchmarks_cpu_gpu.py) | CPU/GPU cold vs warm timing |

Cross-library benchmark scripts (itlog vs pylogit/xlogit/biogeme) live in
[`benchmarks/`](benchmarks/README.md).

## Testing

```bash
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`](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`](tests/backends/test_likelihoods.py) and
degenerate-equivalence tests under `tests/models/`.

## Documentation

- [`docs/feature-map.md`](docs/feature-map.md) — data schemas, model suites, LaTeX export.
- [`docs/benchmarks.md`](docs/benchmarks.md) — validation methodology and parity discussion.
- [`docs/benchmark-results.md`](docs/benchmark-results.md) — measured speed and accuracy (M4 CPU).

## License

MIT — see [LICENSE](LICENSE).
