mnlite 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mnlite/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """mnlite: lightweight multinomial and nested logit models for travel mode choice.
2
+
3
+ Estimation by maximum likelihood with analytic gradients (numpy only), robust
4
+ standard errors, goodness-of-fit statistics, likelihood ratio tests,
5
+ elasticities, value of travel time with delta-method standard errors and
6
+ market-share scenario simulation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ._version import __version__
12
+ from .data import ChoiceData, DataError, read_csv
13
+ from .datasets import ben_akiva_lerman, synthetic_mode_choice, synthetic_spec
14
+ from .estimation import (
15
+ EstimationResults,
16
+ LRTest,
17
+ RatioEstimate,
18
+ ScenarioResult,
19
+ compare,
20
+ estimate,
21
+ lr_test,
22
+ )
23
+ from .models import ChoiceModel
24
+ from .spec import Spec, SpecError, Term, parse_utility
25
+
26
+ __all__ = [
27
+ "__version__",
28
+ "ChoiceData",
29
+ "ChoiceModel",
30
+ "DataError",
31
+ "EstimationResults",
32
+ "LRTest",
33
+ "RatioEstimate",
34
+ "ScenarioResult",
35
+ "Spec",
36
+ "SpecError",
37
+ "Term",
38
+ "ben_akiva_lerman",
39
+ "compare",
40
+ "estimate",
41
+ "lr_test",
42
+ "parse_utility",
43
+ "read_csv",
44
+ "synthetic_mode_choice",
45
+ "synthetic_spec",
46
+ ]
mnlite/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """``python -m mnlite``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
mnlite/_stats.py ADDED
@@ -0,0 +1,86 @@
1
+ """Small distribution helpers so that mnlite does not need scipy.
2
+
3
+ The chi-square survival function uses the regularised upper incomplete gamma
4
+ function evaluated with the series / continued-fraction pair described in
5
+ Press, Teukolsky, Vetterling & Flannery (2007), *Numerical Recipes*, 3rd ed.,
6
+ section 6.2.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+
13
+ __all__ = ["norm_sf", "norm_ppf", "chi2_sf"]
14
+
15
+
16
+ def norm_sf(z: float) -> float:
17
+ """Upper tail probability ``P(Z > z)`` of the standard normal distribution."""
18
+ return 0.5 * math.erfc(z / math.sqrt(2.0))
19
+
20
+
21
+ def norm_ppf(p: float) -> float:
22
+ """Quantile of the standard normal distribution (bisection on ``erfc``).
23
+
24
+ Accurate to about 1e-12, which is ample for confidence intervals.
25
+ """
26
+ if not 0.0 < p < 1.0:
27
+ raise ValueError("p must lie strictly between 0 and 1")
28
+ lo, hi = -40.0, 40.0
29
+ for _ in range(200):
30
+ mid = 0.5 * (lo + hi)
31
+ if 1.0 - norm_sf(mid) < p:
32
+ lo = mid
33
+ else:
34
+ hi = mid
35
+ if hi - lo < 1e-14:
36
+ break
37
+ return 0.5 * (lo + hi)
38
+
39
+
40
+ def _gamma_p_series(a: float, x: float) -> float:
41
+ term = 1.0 / a
42
+ total = term
43
+ ap = a
44
+ for _ in range(10_000):
45
+ ap += 1.0
46
+ term *= x / ap
47
+ total += term
48
+ if abs(term) < abs(total) * 1e-16:
49
+ break
50
+ return total * math.exp(-x + a * math.log(x) - math.lgamma(a))
51
+
52
+
53
+ def _gamma_q_cf(a: float, x: float) -> float:
54
+ tiny = 1e-300
55
+ b = x + 1.0 - a
56
+ c = 1.0 / tiny
57
+ d = 1.0 / b
58
+ h = d
59
+ for i in range(1, 10_000):
60
+ an = -i * (i - a)
61
+ b += 2.0
62
+ d = an * d + b
63
+ if abs(d) < tiny:
64
+ d = tiny
65
+ c = b + an / c
66
+ if abs(c) < tiny:
67
+ c = tiny
68
+ d = 1.0 / d
69
+ delta = d * c
70
+ h *= delta
71
+ if abs(delta - 1.0) < 1e-16:
72
+ break
73
+ return math.exp(-x + a * math.log(x) - math.lgamma(a)) * h
74
+
75
+
76
+ def chi2_sf(x: float, df: float) -> float:
77
+ """Survival function ``P(X > x)`` of a chi-square variable with ``df`` degrees of freedom."""
78
+ if df <= 0:
79
+ raise ValueError("df must be positive")
80
+ if x <= 0:
81
+ return 1.0
82
+ a = 0.5 * df
83
+ half = 0.5 * x
84
+ if half < a + 1.0:
85
+ return max(0.0, 1.0 - _gamma_p_series(a, half))
86
+ return min(1.0, _gamma_q_cf(a, half))
mnlite/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
mnlite/cli.py ADDED
@@ -0,0 +1,246 @@
1
+ """Command line interface: ``mnlite estimate | synth | convert | example``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from typing import Any, Dict, List, Optional, Sequence
9
+
10
+ from ._version import __version__
11
+ from .data import ChoiceData, read_table
12
+ from .datasets import BEN_AKIVA_LERMAN_SPEC, ben_akiva_lerman, synthetic_mode_choice
13
+ from .estimation import EstimationResults, estimate
14
+ from .spec import Spec
15
+
16
+ __all__ = ["main"]
17
+
18
+
19
+ def _kv(items: Optional[Sequence[str]], sep: str, what: str) -> Dict[str, str]:
20
+ out: Dict[str, str] = {}
21
+ for it in items or []:
22
+ if sep not in it:
23
+ raise SystemExit(f"error: {what} {it!r} must look like NAME{sep}VALUE")
24
+ k, v = it.split(sep, 1)
25
+ out[k.strip()] = v.strip()
26
+ return out
27
+
28
+
29
+ def _load_data(args: argparse.Namespace, alts: Optional[List[str]]) -> ChoiceData:
30
+ table = read_table(args.data)
31
+ if args.long:
32
+ return ChoiceData.from_long(
33
+ table,
34
+ id_col=args.id_col,
35
+ alt_col=args.alt_col,
36
+ choice_col=args.choice,
37
+ alternatives=alts,
38
+ avail_col=args.avail,
39
+ weights=args.weights,
40
+ )
41
+ return ChoiceData.from_wide(
42
+ table, alternatives=alts, choice=args.choice, avail=args.avail, weights=args.weights
43
+ )
44
+
45
+
46
+ def _add_data_args(p: argparse.ArgumentParser, choice_default: str = "choice") -> None:
47
+ p.add_argument("data", help="input CSV file")
48
+ p.add_argument("--long", action="store_true", help="input is in long form")
49
+ p.add_argument(
50
+ "--choice",
51
+ default=None,
52
+ help=f"choice column (wide: chosen alternative, default {choice_default!r}; "
53
+ "long: 0/1 indicator, default 'chosen')",
54
+ )
55
+ p.add_argument("--id-col", default="id", help="long form: observation id column")
56
+ p.add_argument("--alt-col", default="alt", help="long form: alternative column")
57
+ p.add_argument(
58
+ "--avail",
59
+ default=None,
60
+ help="wide: availability column pattern such as '{alt}_av'; long: 0/1 column",
61
+ )
62
+ p.add_argument("--weights", default=None, help="observation weight column")
63
+ p.add_argument("--alts", default=None, help="comma-separated alternatives (sets the order)")
64
+
65
+
66
+ def _fix_choice_default(args: argparse.Namespace) -> None:
67
+ if args.choice is None:
68
+ args.choice = "chosen" if args.long else "choice"
69
+
70
+
71
+ def _build_spec(args: argparse.Namespace) -> Spec:
72
+ if args.spec:
73
+ with open(args.spec, encoding="utf-8") as fh:
74
+ d = json.load(fh)
75
+ return Spec.from_dict(d)
76
+ utils = _kv(args.utility, "=", "--utility")
77
+ if not utils:
78
+ raise SystemExit("error: give --spec FILE.json or at least one --utility ALT=EXPR")
79
+ nests = {k: [m.strip() for m in v.split(",")] for k, v in _kv(args.nest, ":", "--nest").items()}
80
+ fixed = {k: float(v) for k, v in _kv(args.fix, "=", "--fix").items()}
81
+ alts = args.alts.split(",") if args.alts else None
82
+ if alts:
83
+ for a in alts:
84
+ utils.setdefault(a.strip(), "0")
85
+ return Spec(utils, nests=nests or None, fixed=fixed, alternatives=alts)
86
+
87
+
88
+ def _report(res: EstimationResults, data: ChoiceData, args: argparse.Namespace) -> Dict[str, Any]:
89
+ extra: Dict[str, Any] = {}
90
+ for item in args.ratio or []:
91
+ scale = 1.0
92
+ expr = item
93
+ if "*" in expr:
94
+ expr, s = expr.split("*", 1)
95
+ scale = float(s)
96
+ num, den = (x.strip() for x in expr.split("/", 1))
97
+ r = res.ratio(num, den, scale, robust=args.robust)
98
+ print(r)
99
+ extra.setdefault("ratios", {})[r.name] = {"value": r.value, "se": r.se, "ci": list(r.ci)}
100
+ for item in args.elasticity or []:
101
+ if ":" in item:
102
+ var, alt = item.split(":", 1)
103
+ e = res.elasticities(data, var.strip(), alt.strip())
104
+ else:
105
+ e = res.elasticities(data, item.strip())
106
+ assert isinstance(e, dict)
107
+ print(f"Aggregate elasticities w.r.t. {item}:")
108
+ for a, v in e.items():
109
+ print(f" P({a}): {v:+.4f}")
110
+ extra.setdefault("elasticities", {})[item] = e
111
+ if args.scenario:
112
+ changes: Dict[str, str] = {}
113
+ for sc in args.scenario:
114
+ for op in ("*", "+", "=", "/", "-"):
115
+ if op in sc:
116
+ col, val = sc.split(op, 1)
117
+ changes[col.strip()] = op + val.strip()
118
+ break
119
+ else:
120
+ raise SystemExit(
121
+ f"error: cannot parse scenario {sc!r}; use COL*1.1, COL+5, COL-5, COL=0"
122
+ )
123
+ sim = res.simulate(data, changes)
124
+ print("Scenario: " + ", ".join(f"{k}{v}" for k, v in changes.items()))
125
+ print(sim.table())
126
+ extra["scenario"] = {"changes": changes, "base": sim.base, "scenario": sim.scenario}
127
+ return extra
128
+
129
+
130
+ def _cmd_estimate(args: argparse.Namespace) -> int:
131
+ _fix_choice_default(args)
132
+ spec = _build_spec(args)
133
+ data = _load_data(args, spec.alts)
134
+ res = estimate(spec, data, method=args.method, constants_ll=not args.no_constants)
135
+ print(res.summary(robust=True))
136
+ extra = _report(res, data, args)
137
+ if args.json:
138
+ d = res.to_dict()
139
+ d.update(extra)
140
+ with open(args.json, "w", encoding="utf-8") as fh:
141
+ json.dump(d, fh, indent=2)
142
+ print(f"results written to {args.json}")
143
+ return 0 if res.converged else 1
144
+
145
+
146
+ def _cmd_synth(args: argparse.Namespace) -> int:
147
+ data = synthetic_mode_choice(args.n, args.seed, nested=args.nested)
148
+ data.write_csv(args.out, "long" if args.long else "wide")
149
+ print(f"wrote {data.n} observations to {args.out}")
150
+ return 0
151
+
152
+
153
+ def _cmd_convert(args: argparse.Namespace) -> int:
154
+ _fix_choice_default(args)
155
+ alts = args.alts.split(",") if args.alts else None
156
+ data = _load_data(args, alts)
157
+ data.write_csv(args.out, args.to)
158
+ print(f"wrote {data.n} observations ({args.to} form) to {args.out}")
159
+ return 0
160
+
161
+
162
+ def _cmd_example(args: argparse.Namespace) -> int:
163
+ data = ben_akiva_lerman()
164
+ res = estimate(BEN_AKIVA_LERMAN_SPEC, data)
165
+ print("Ben-Akiva & Lerman (1985, p. 88) car/transit example")
166
+ print(res.summary(robust=False))
167
+ print("Published: asc_transit 0.2376 (0.7505), b_time -0.0531 (0.0206), LL -6.166")
168
+ return 0
169
+
170
+
171
+ def build_parser() -> argparse.ArgumentParser:
172
+ p = argparse.ArgumentParser(
173
+ prog="mnlite",
174
+ description="Lightweight multinomial / nested logit estimation for mode choice.",
175
+ )
176
+ p.add_argument("--version", action="version", version=f"mnlite {__version__}")
177
+ sub = p.add_subparsers(dest="command", required=True)
178
+
179
+ e = sub.add_parser("estimate", help="estimate an MNL or nested logit model from a CSV")
180
+ _add_data_args(e)
181
+ e.add_argument("--spec", help="JSON spec file with 'utilities', 'nests', 'fixed'")
182
+ e.add_argument(
183
+ "-u",
184
+ "--utility",
185
+ action="append",
186
+ metavar="ALT=EXPR",
187
+ help="utility of one alternative, e.g. 'bus=asc_bus + b_time*time' (repeatable)",
188
+ )
189
+ e.add_argument("--nest", action="append", metavar="NEST:ALT1,ALT2", help="nest (repeatable)")
190
+ e.add_argument("--fix", action="append", metavar="PARAM=VALUE", help="fix a parameter")
191
+ e.add_argument("--method", default="auto", choices=["auto", "newton", "bfgs", "scipy"])
192
+ e.add_argument("--robust", action="store_true", help="use robust s.e. for ratios")
193
+ e.add_argument(
194
+ "--ratio",
195
+ action="append",
196
+ metavar="NUM/DEN[*SCALE]",
197
+ help="parameter ratio with delta-method s.e., e.g. VTT 'b_time/b_cost*60'",
198
+ )
199
+ e.add_argument(
200
+ "--elasticity",
201
+ action="append",
202
+ metavar="VAR:ALT|COLUMN",
203
+ help="aggregate elasticities w.r.t. a variable, e.g. 'cost:car' or 'cost_car'",
204
+ )
205
+ e.add_argument(
206
+ "--scenario",
207
+ action="append",
208
+ metavar="COL*X|COL+X|COL-X|COL=X",
209
+ help="scenario change of a column (repeatable; applied together)",
210
+ )
211
+ e.add_argument("--no-constants", action="store_true", help="skip the constants-only model")
212
+ e.add_argument("--json", help="write results to this JSON file")
213
+ e.set_defaults(func=_cmd_estimate)
214
+
215
+ s = sub.add_parser("synth", help="write the synthetic car/bus/rail dataset")
216
+ s.add_argument("out", help="output CSV")
217
+ s.add_argument("--n", type=int, default=2000)
218
+ s.add_argument("--seed", type=int, default=12345)
219
+ s.add_argument("--nested", action="store_true", help="simulate from the nested logit")
220
+ s.add_argument("--long", action="store_true", help="write long form")
221
+ s.set_defaults(func=_cmd_synth)
222
+
223
+ c = sub.add_parser("convert", help="convert between wide and long CSV")
224
+ _add_data_args(c)
225
+ c.add_argument("out", help="output CSV")
226
+ c.add_argument("--to", choices=["wide", "long"], required=True)
227
+ c.set_defaults(func=_cmd_convert)
228
+
229
+ x = sub.add_parser("example", help="run the Ben-Akiva & Lerman validation example")
230
+ x.set_defaults(func=_cmd_example)
231
+ return p
232
+
233
+
234
+ def main(argv: Optional[Sequence[str]] = None) -> int:
235
+ parser = build_parser()
236
+ args = parser.parse_args(argv)
237
+ try:
238
+ rc: int = args.func(args)
239
+ except (ValueError, KeyError, OSError) as exc:
240
+ print(f"mnlite: error: {exc}", file=sys.stderr)
241
+ return 2
242
+ return rc
243
+
244
+
245
+ if __name__ == "__main__": # pragma: no cover
246
+ sys.exit(main())