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 +46 -0
- mnlite/__main__.py +7 -0
- mnlite/_stats.py +86 -0
- mnlite/_version.py +1 -0
- mnlite/cli.py +246 -0
- mnlite/data.py +522 -0
- mnlite/datasets.py +156 -0
- mnlite/estimation.py +658 -0
- mnlite/models.py +332 -0
- mnlite/optimize.py +173 -0
- mnlite/py.typed +0 -0
- mnlite/spec.py +280 -0
- mnlite-0.1.0.dist-info/METADATA +271 -0
- mnlite-0.1.0.dist-info/RECORD +18 -0
- mnlite-0.1.0.dist-info/WHEEL +5 -0
- mnlite-0.1.0.dist-info/entry_points.txt +2 -0
- mnlite-0.1.0.dist-info/licenses/LICENSE +21 -0
- mnlite-0.1.0.dist-info/top_level.txt +1 -0
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
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())
|