logit-stream 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {logit_stream-0.1.0 → logit_stream-0.2.0}/CHANGELOG.md +32 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/CITATION.cff +2 -2
- {logit_stream-0.1.0 → logit_stream-0.2.0}/PKG-INFO +3 -2
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/__init__.py +1 -1
- logit_stream-0.2.0/logit_stream/_limits.py +31 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/api.py +1 -1
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/estimator.py +37 -33
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/fe.py +7 -6
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/mixed.py +12 -1
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/nested.py +12 -1
- logit_stream-0.2.0/logit_stream/profile.py +270 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/random_effects/model.py +9 -5
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream.egg-info/PKG-INFO +3 -2
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream.egg-info/SOURCES.txt +5 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream.egg-info/requires.txt +2 -1
- {logit_stream-0.1.0 → logit_stream-0.2.0}/pyproject.toml +3 -2
- logit_stream-0.2.0/tests/test_headroom.py +61 -0
- logit_stream-0.2.0/tests/test_index_dtype.py +51 -0
- logit_stream-0.2.0/tests/test_profile.py +161 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/LICENSE.md +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/MANIFEST.in +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/README.md +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/_choice.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/_frontend.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/__init__.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/_common.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/fe.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/meat.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/mixed.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/models.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/nested.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/random_effects.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/store.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/models.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/multi_fe.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/newton.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/optimize.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/py.typed +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/random_effects/__init__.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/random_effects/fit.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/random_effects/linalg.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/random_effects/sigma.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/results.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/simulate.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/store.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream.egg-info/dependency_links.txt +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream.egg-info/top_level.txt +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/setup.cfg +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_api.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_cfelogit.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_clustering.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_conditional.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_device.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_firth_and_fit.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_fixed_effects.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_kernels.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_mixed.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_multi_fe.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_multinomial.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_nested.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_offset_start.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_ordered.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_probit.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_random.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_simulate.py +0 -0
- {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_weights_scale.py +0 -0
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- **Fit resources (CPUs and memory) are constrained to your job's limits automatically.** On a
|
|
6
|
+
cluster (PBS, Slurm, or a container with cgroup limits), importing `logit_stream` caps the threads of
|
|
7
|
+
Polars, numba, BLAS and pyarrow at the job's CPU allocation, and with Polars 2.x gives Polars a
|
|
8
|
+
memory budget so its streaming operations spill to disk instead of growing. Caveats: the Polars
|
|
9
|
+
budget is a target for its buffers, not a hard cap on the process, and Polars 1.x has no such
|
|
10
|
+
option, so there only the threads are capped. The caps must be in place before Polars is
|
|
11
|
+
imported: import `logit_stream` first, or call `stream_limits.set_limits()` at the top of the script.
|
|
12
|
+
With no limit found nothing changes. See "Resource limits and profiling" in the documentation.
|
|
13
|
+
- **`profile=True`: estimate a fit's memory and disk before running it.** On
|
|
14
|
+
every estimator, profiling returns a `hdfe_stream.Profile` -- the estimated
|
|
15
|
+
memory and disk, from one scan of the data -- instead of fitting. P comes from
|
|
16
|
+
the model and the outcome's levels; the information matrix, its per-thread
|
|
17
|
+
copies, the fixed effect's cross blocks and the clusters' score sums are sized
|
|
18
|
+
from it. The multinomial logit with several fixed effects (fitted by
|
|
19
|
+
`fepois_stream` on data expanded J-fold) is profiled as that regression, from
|
|
20
|
+
counts worked out from one scan of the original data. Not modeled: random
|
|
21
|
+
intercepts, and the mixed and nested logit's workspaces beyond the per-thread
|
|
22
|
+
accumulators.
|
|
23
|
+
- **`max_memory_gb` and `memory_reserve`**: the fixed effect's cross-block guard and
|
|
24
|
+
the random intercepts' guard (both a fixed 8 GB) and CRV3's (25% of physical
|
|
25
|
+
memory) now ask whether their exact allocation fits in the memory left (the limit
|
|
26
|
+
less a reserve, less what the process holds). The limit defaults to the job's
|
|
27
|
+
allocation (cgroup, `STREAM_LIMITS_MEMORY_GB`, or on PBS `qstat`).
|
|
28
|
+
- **`n_threads=`** on every estimator: numba and BLAS threads for one fit,
|
|
29
|
+
restored afterwards (Polars' pool is fixed at import; see hdfe-stream's
|
|
30
|
+
"Resource limits and profiling").
|
|
31
|
+
- **`diagnostics["peak_rss_gb"]`** in every fit.
|
|
32
|
+
- Depends on `stream-limits`, and needs hdfe-stream 0.8.0 or later (the `<0.7` upper bound on
|
|
33
|
+
hdfe-stream is gone).
|
|
34
|
+
|
|
3
35
|
## 0.1.0
|
|
4
36
|
|
|
5
37
|
The first release: logit models by streamed Newton's method, for data that
|
|
@@ -7,8 +7,8 @@ authors:
|
|
|
7
7
|
- family-names: Tucker
|
|
8
8
|
given-names: Lee
|
|
9
9
|
email: lee.c.tucker@census.gov
|
|
10
|
-
version: 0.
|
|
11
|
-
date-released: 2026-10-
|
|
10
|
+
version: 0.2.0
|
|
11
|
+
date-released: 2026-10-31
|
|
12
12
|
license: MIT
|
|
13
13
|
repository-code: "https://github.com/leetucker/logit-stream"
|
|
14
14
|
url: "https://github.com/leetucker/logit-stream"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: logit-stream
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Out-of-core logit models by streamed Newton: binary, multinomial, ordered and conditional logit, with absorbed fixed effects
|
|
5
5
|
Author: Lee Tucker
|
|
6
6
|
License-Expression: MIT
|
|
@@ -25,7 +25,8 @@ Classifier: Typing :: Typed
|
|
|
25
25
|
Requires-Python: >=3.10
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
License-File: LICENSE.md
|
|
28
|
-
Requires-Dist: hdfe-stream[formula]
|
|
28
|
+
Requires-Dist: hdfe-stream[formula]>=0.8.0
|
|
29
|
+
Requires-Dist: stream-limits>=0.1.1
|
|
29
30
|
Requires-Dist: polars!=1.41.*,>=1.37
|
|
30
31
|
Requires-Dist: numpy>=1.24
|
|
31
32
|
Requires-Dist: numba>=0.59
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Resource limits for a fit: the `n_threads=` option, and the peak memory the
|
|
2
|
+
fit used. The code is hdfe-stream's (`hdfe_stream.core.resources`); see
|
|
3
|
+
"Resource limits and profiling" in its documentation."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import functools
|
|
8
|
+
|
|
9
|
+
from hdfe_stream.core.profile import Profile
|
|
10
|
+
from hdfe_stream.core.resources import peak_rss_gb, reset_peak_rss, thread_scope
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def limited(fit):
|
|
14
|
+
"""Give an estimator's fit function an ``n_threads`` keyword that holds
|
|
15
|
+
numba and BLAS to that many threads for the fit (restored afterwards; Polars
|
|
16
|
+
cannot be limited per fit), and record ``diagnostics["peak_rss_gb"]``."""
|
|
17
|
+
|
|
18
|
+
@functools.wraps(fit)
|
|
19
|
+
def wrapper(*args, n_threads=None, **kwargs):
|
|
20
|
+
reset_peak_rss()
|
|
21
|
+
with thread_scope(n_threads):
|
|
22
|
+
out = fit(*args, **kwargs)
|
|
23
|
+
rss = peak_rss_gb()
|
|
24
|
+
rss = None if rss is None else round(rss, 3)
|
|
25
|
+
if isinstance(out, Profile):
|
|
26
|
+
return out
|
|
27
|
+
for r in ([out] if hasattr(out, "diagnostics") else out):
|
|
28
|
+
r.diagnostics["peak_rss_gb"] = rss
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
return wrapper
|
|
@@ -342,7 +342,7 @@ mlogit_stream.__doc__ = """Out-of-core multinomial logit by streamed Newton.
|
|
|
342
342
|
distinct values, sorted.
|
|
343
343
|
poisson_options : dict, optional
|
|
344
344
|
With several fixed effects: further options of hdfe-stream's
|
|
345
|
-
`fepois_stream` (``solver``, ``precond``, ``
|
|
345
|
+
`fepois_stream` (``solver``, ``precond``, ``max_memory_gb``,
|
|
346
346
|
``fixef_rm``, ...), except those the multinomial logit sets itself
|
|
347
347
|
(vcov, cluster, weights, keep, workdir).""" + _RANDOM + """
|
|
348
348
|
|
|
@@ -12,7 +12,6 @@ from __future__ import annotations
|
|
|
12
12
|
|
|
13
13
|
import logging
|
|
14
14
|
import os
|
|
15
|
-
import sys
|
|
16
15
|
import tempfile
|
|
17
16
|
import time
|
|
18
17
|
from collections.abc import Sequence
|
|
@@ -23,11 +22,13 @@ from typing import Any
|
|
|
23
22
|
import numpy as np
|
|
24
23
|
import polars as pl
|
|
25
24
|
from hdfe_stream.core.report import warn
|
|
25
|
+
from hdfe_stream.core.resources import MemoryBudget
|
|
26
26
|
from hdfe_stream.core.results_base import vcov_key
|
|
27
27
|
from hdfe_stream.core.store import PolarsEngine
|
|
28
28
|
from hdfe_stream.core.workspace import Run
|
|
29
29
|
|
|
30
30
|
from ._frontend import _plan
|
|
31
|
+
from ._limits import limited
|
|
31
32
|
from .models import _CFELogit, _Conditional, _Multinomial, _Ordered
|
|
32
33
|
from .newton import _Driver
|
|
33
34
|
from .random_effects import _fit_random, _RandomEffects, _refine_laplace, _sigma_inference
|
|
@@ -96,6 +97,7 @@ def _clusterings(vcov, cluster):
|
|
|
96
97
|
return key, clusters, hetero, requests
|
|
97
98
|
|
|
98
99
|
|
|
100
|
+
@limited
|
|
99
101
|
def _fit(kind: str, fml: str, data: Any, *, workdir=None, vcov="iid",
|
|
100
102
|
cluster: Sequence[str] | str = (), weights: str | None = None,
|
|
101
103
|
weights_type: str = "aweights", levels: Sequence | None = None,
|
|
@@ -106,7 +108,9 @@ def _fit(kind: str, fml: str, data: Any, *, workdir=None, vcov="iid",
|
|
|
106
108
|
log_level=None, random: str | None = None, random_cov: str = "unstructured",
|
|
107
109
|
random_tol: float = 1e-6, random_method: str = "laplace", firth: bool = False,
|
|
108
110
|
offset=None, start=None, device: str = "cpu", link: str = "logit",
|
|
109
|
-
altwise: bool = False, poisson_options: dict | None = None
|
|
111
|
+
altwise: bool = False, poisson_options: dict | None = None,
|
|
112
|
+
profile: bool = False, n_threads: int | None = None,
|
|
113
|
+
max_memory_gb: float | None = None, memory_reserve: float | None = None):
|
|
110
114
|
if poisson_options is not None and kind != "mlogit":
|
|
111
115
|
raise ValueError("poisson_options= is for mlogit_stream with several fixed effects")
|
|
112
116
|
if weights_type not in ("aweights", "fweights"):
|
|
@@ -172,6 +176,7 @@ def _fit(kind: str, fml: str, data: Any, *, workdir=None, vcov="iid",
|
|
|
172
176
|
if l2:
|
|
173
177
|
raise ValueError("l2= and random= cannot be combined")
|
|
174
178
|
vkey, clusters, hetero, requests = _clusterings(vcov, cluster)
|
|
179
|
+
budget = MemoryBudget(max_memory_gb, memory_reserve)
|
|
175
180
|
|
|
176
181
|
def log(msg):
|
|
177
182
|
_log(verbose, msg, logger, log_level)
|
|
@@ -214,6 +219,14 @@ def _fit(kind: str, fml: str, data: Any, *, workdir=None, vcov="iid",
|
|
|
214
219
|
raise ValueError(f"{spec.fml}: firth= is not available with an absorbed "
|
|
215
220
|
"fixed effect")
|
|
216
221
|
if len(spec.fe) > 1 and kind == "mlogit":
|
|
222
|
+
if profile:
|
|
223
|
+
from .profile import profile_multi_fe
|
|
224
|
+
results.append(profile_multi_fe(
|
|
225
|
+
spec, lf, data, weights=weights, weights_type=weights_type,
|
|
226
|
+
clusters=clusters, requests=requests, hetero=hetero, vkey=vkey,
|
|
227
|
+
levels=levels, options=poisson_options, budget=budget,
|
|
228
|
+
n_threads=n_threads, tol=tol, maxiter=maxiter))
|
|
229
|
+
continue
|
|
217
230
|
# several: as a Poisson regression on the data expanded by level
|
|
218
231
|
bad = [nm for nm, v in (("l2=", l2), ("start=", start),
|
|
219
232
|
("storage='memory'", storage == "memory"))
|
|
@@ -236,6 +249,14 @@ def _fit(kind: str, fml: str, data: Any, *, workdir=None, vcov="iid",
|
|
|
236
249
|
raise ValueError(f"{spec.fml}: one absorbed fixed effect is available for "
|
|
237
250
|
f"{_KINDS[kind]} (got {list(spec.fe)}); put small "
|
|
238
251
|
"dimensions in as C(var)")
|
|
252
|
+
if profile:
|
|
253
|
+
from .profile import profile_model
|
|
254
|
+
results.append(profile_model(
|
|
255
|
+
kind, spec, lf, data, weights=weights, clusters=clusters, requests=requests,
|
|
256
|
+
case=case, levels=levels, storage=storage, dtype=dtype, block_mb=block_mb,
|
|
257
|
+
chunk_mb=chunk_mb, offset=offset, random=random, firth=firth,
|
|
258
|
+
n_threads=n_threads, budget=budget))
|
|
259
|
+
continue
|
|
239
260
|
results.append(_fit_one(kind, spec, lf, engine, vkey=vkey, clusters=clusters,
|
|
240
261
|
requests=requests,
|
|
241
262
|
hetero=hetero, weights=weights, weights_type=weights_type,
|
|
@@ -246,7 +267,10 @@ def _fit(kind: str, fml: str, data: Any, *, workdir=None, vcov="iid",
|
|
|
246
267
|
logger=logger, random=random, random_cov=random_cov,
|
|
247
268
|
random_tol=random_tol, random_method=random_method,
|
|
248
269
|
firth=firth, offset=offset, start=start, device=device,
|
|
249
|
-
link=link, altwise=altwise))
|
|
270
|
+
link=link, altwise=altwise, budget=budget))
|
|
271
|
+
if profile:
|
|
272
|
+
from .profile import combine_profiles
|
|
273
|
+
return combine_profiles(results)
|
|
250
274
|
return results[0] if len(results) == 1 else LogitMulti(results)
|
|
251
275
|
|
|
252
276
|
|
|
@@ -254,7 +278,7 @@ def _fit_one(kind, spec, lf, engine, *, vkey, clusters, requests, hetero, weight
|
|
|
254
278
|
levels, case, tol, maxiter, l2, storage, dtype, block_mb, chunk_mb, workdir,
|
|
255
279
|
keep_intermediates, log, logger, random=None, random_cov="unstructured",
|
|
256
280
|
random_tol=1e-6, random_method="laplace", firth=False, offset=None, start=None,
|
|
257
|
-
device=None, link="logit", altwise=False):
|
|
281
|
+
device=None, link="logit", altwise=False, budget=None):
|
|
258
282
|
t0 = time.time()
|
|
259
283
|
log(f"{_KINDS[kind]}: {spec.fml}")
|
|
260
284
|
run = Run(_base_dir(workdir), auto_cleanup=not keep_intermediates)
|
|
@@ -290,7 +314,7 @@ def _fit_one(kind, spec, lf, engine, *, vkey, clusters, requests, hetero, weight
|
|
|
290
314
|
else None
|
|
291
315
|
if random is not None:
|
|
292
316
|
keys = {nm: ("fe" if i == 0 else f"fe{i + 1}") for i, nm in enumerate(spec.fe)}
|
|
293
|
-
fe = _RandomEffects(model, p0, random, outer, keys, log=log, logger=logger,
|
|
317
|
+
fe = _RandomEffects(model, p0, random, outer, keys, log=log, logger=logger, budget=budget,
|
|
294
318
|
cov=random_cov)
|
|
295
319
|
if clusters:
|
|
296
320
|
cl = {k: (i, 0) for i, k in enumerate(clusters)}
|
|
@@ -302,7 +326,7 @@ def _fit_one(kind, spec, lf, engine, *, vkey, clusters, requests, hetero, weight
|
|
|
302
326
|
f"crossed groupings allow none; {bad} do not")
|
|
303
327
|
elif spec.fe:
|
|
304
328
|
from .fe import _AbsorbedFE
|
|
305
|
-
fe = _AbsorbedFE(model, p0, spec.fe[0], log=log, logger=logger)
|
|
329
|
+
fe = _AbsorbedFE(model, p0, spec.fe[0], log=log, logger=logger, budget=budget)
|
|
306
330
|
driver = _Driver(p0.store, model, tol=tol, maxiter=maxiter, l2=l2, firth=firth,
|
|
307
331
|
chunk_mb=chunk_mb, device=device,
|
|
308
332
|
log=log, logger=logger, fe=fe)
|
|
@@ -366,7 +390,8 @@ def _fit_one(kind, spec, lf, engine, *, vkey, clusters, requests, hetero, weight
|
|
|
366
390
|
raw = {k: _sandwich(fe, fit, Hinv, U[k].T @ U[k]) for k in cl_index}
|
|
367
391
|
for req, parts in requests.items():
|
|
368
392
|
if req.startswith("CRV3:"):
|
|
369
|
-
all_vcovs[req] = _crv3(driver, fit, U, cl_index, parts[0][0], spec, logger
|
|
393
|
+
all_vcovs[req] = _crv3(driver, fit, U, cl_index, parts[0][0], spec, logger,
|
|
394
|
+
budget)
|
|
370
395
|
n_clusters[req.split(":", 1)[1]] = cl_index[parts[0][0]][1]
|
|
371
396
|
continue
|
|
372
397
|
Vc, df, Gs = _clustered(raw, parts, cl_index, req, spec.fml, logger)
|
|
@@ -502,29 +527,7 @@ def _warm_start(theta0, start, model, fml):
|
|
|
502
527
|
return th
|
|
503
528
|
|
|
504
529
|
|
|
505
|
-
def
|
|
506
|
-
"""The machine's physical memory in GB: sysconf where there is one, the
|
|
507
|
-
Windows API otherwise; 16 if neither answers."""
|
|
508
|
-
try:
|
|
509
|
-
return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / 1e9
|
|
510
|
-
except (AttributeError, ValueError, OSError):
|
|
511
|
-
pass
|
|
512
|
-
if sys.platform == "win32":
|
|
513
|
-
import ctypes
|
|
514
|
-
|
|
515
|
-
class _Status(ctypes.Structure):
|
|
516
|
-
_fields_ = [("length", ctypes.c_ulong), ("load", ctypes.c_ulong),
|
|
517
|
-
("total", ctypes.c_ulonglong), ("avail", ctypes.c_ulonglong),
|
|
518
|
-
("page_total", ctypes.c_ulonglong), ("page_avail", ctypes.c_ulonglong),
|
|
519
|
-
("virt_total", ctypes.c_ulonglong), ("virt_avail", ctypes.c_ulonglong),
|
|
520
|
-
("ext", ctypes.c_ulonglong)]
|
|
521
|
-
status = _Status(length=ctypes.sizeof(_Status))
|
|
522
|
-
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)):
|
|
523
|
-
return status.total / 1e9
|
|
524
|
-
return 16.0
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
def _crv3(driver, fit, U, cl_index, key, spec, logger):
|
|
530
|
+
def _crv3(driver, fit, U, cl_index, key, spec, logger, budget=None):
|
|
528
531
|
"""The cluster jackknife (CV3; MacKinnon, Nielsen and Webb 2023):
|
|
529
532
|
(G - 1) / G sum_g d_g d_g', with d_g the change in the estimates from
|
|
530
533
|
dropping cluster g, by one Newton step from them -- the information
|
|
@@ -535,11 +538,12 @@ def _crv3(driver, fit, U, cl_index, key, spec, logger):
|
|
|
535
538
|
col, G = cl_index[key]
|
|
536
539
|
P = len(fit.theta)
|
|
537
540
|
need = G * P * P * 8 / 1e9
|
|
538
|
-
|
|
539
|
-
if
|
|
541
|
+
budget = budget if budget is not None else MemoryBudget()
|
|
542
|
+
if not budget.fits(need):
|
|
540
543
|
raise MemoryError(f"{spec.fml}: CRV3 by {key.split(':', 1)[1]!r} needs {need:.1f} GB "
|
|
541
544
|
f"for each cluster's information ({G:,} clusters x {P} parameters "
|
|
542
|
-
"squared); use
|
|
545
|
+
f"squared) and {budget.headroom_gb():.1f} GB is free ({budget}); use "
|
|
546
|
+
"CRV1, or raise max_memory_gb")
|
|
543
547
|
Hg = driver.cluster_information(fit.theta, col, G)
|
|
544
548
|
H, S = fit.H, U[key]
|
|
545
549
|
D = np.empty((G, P))
|
|
@@ -44,6 +44,7 @@ import numba as nb
|
|
|
44
44
|
import numpy as np
|
|
45
45
|
import polars as pl
|
|
46
46
|
from hdfe_stream.core.report import warn
|
|
47
|
+
from hdfe_stream.core.resources import MemoryBudget
|
|
47
48
|
from scipy.optimize import minimize
|
|
48
49
|
from scipy.special import xlogy
|
|
49
50
|
|
|
@@ -51,8 +52,6 @@ from .models import _gram, _mnl_info_sparse
|
|
|
51
52
|
from .newton import _chunks, _design, _Driver
|
|
52
53
|
from .store import CODE
|
|
53
54
|
|
|
54
|
-
_MAX_GROUP_GB = 8.0
|
|
55
|
-
|
|
56
55
|
|
|
57
56
|
@nb.njit(parallel=True, cache=True)
|
|
58
57
|
def _mnl_group_info(order, seg, segg, X, P, w, Haa, B):
|
|
@@ -147,8 +146,9 @@ class _AbsorbedFE:
|
|
|
147
146
|
"""The fixed effect's bookkeeping, passes and linear algebra for the
|
|
148
147
|
Newton driver (see the module docstring)."""
|
|
149
148
|
|
|
150
|
-
def __init__(self, model, p0, name, log, logger=None):
|
|
149
|
+
def __init__(self, model, p0, name, log, logger=None, budget=None):
|
|
151
150
|
self.model, self.p0, self.name = model, p0, name
|
|
151
|
+
self.budget = budget if budget is not None else MemoryBudget()
|
|
152
152
|
self.store = p0.store
|
|
153
153
|
self.logger = logger
|
|
154
154
|
G = p0.fe_tables[name].height
|
|
@@ -200,11 +200,12 @@ class _AbsorbedFE:
|
|
|
200
200
|
"outcome never varies")
|
|
201
201
|
self.n_params = int(self.free.sum())
|
|
202
202
|
gb = G * self.m * max(model.P, 1) * 8 * 2 / 1e9
|
|
203
|
-
if gb
|
|
203
|
+
if not self.budget.fits(gb):
|
|
204
204
|
raise MemoryError(
|
|
205
205
|
f"absorbing {name} needs about {gb:.1f} GB for its per-level blocks "
|
|
206
|
-
f"({G:,} levels x {self.m} x {model.P} parameters, twice)
|
|
207
|
-
"
|
|
206
|
+
f"({G:,} levels x {self.m} x {model.P} parameters, twice) and "
|
|
207
|
+
f"{self.budget.headroom_gb():.1f} GB is free ({self.budget}); fit with fewer "
|
|
208
|
+
"covariates, a coarser fixed effect, or a larger max_memory_gb")
|
|
208
209
|
|
|
209
210
|
def loglik_fe_only(self):
|
|
210
211
|
"""The log-likelihood of the model with the fixed effect and no
|
|
@@ -38,6 +38,7 @@ import time
|
|
|
38
38
|
import numba as nb
|
|
39
39
|
import numpy as np
|
|
40
40
|
|
|
41
|
+
from ._limits import limited
|
|
41
42
|
from .newton import _Driver
|
|
42
43
|
from .optimize import _GradientDriver
|
|
43
44
|
|
|
@@ -388,13 +389,16 @@ class _MixedDriver(_GradientDriver):
|
|
|
388
389
|
# fitting
|
|
389
390
|
# --------------------------------------------------------------------------
|
|
390
391
|
|
|
392
|
+
@limited
|
|
391
393
|
def _fit_mixed(fml, data, *, case, panel=None, random, draws=500, halton=True, seed=0,
|
|
392
394
|
drop=100, vcov="iid", workdir=None, tol=1e-8, maxiter=200, switch=np.inf,
|
|
393
395
|
draws_cache_mb=512.0,
|
|
394
396
|
storage="disk", dtype="float64", block_mb=64.0, polars_engine="cpu",
|
|
395
397
|
keep_intermediates=False, verbose=True, logger=None, log_level=None,
|
|
396
|
-
device="cpu"
|
|
398
|
+
device="cpu", profile=False, chunk_mb=64.0, max_memory_gb=None,
|
|
399
|
+
memory_reserve=None):
|
|
397
400
|
from hdfe_stream.core.report import warn
|
|
401
|
+
from hdfe_stream.core.resources import MemoryBudget
|
|
398
402
|
from hdfe_stream.core.results_base import vcov_key
|
|
399
403
|
from hdfe_stream.core.store import PolarsEngine
|
|
400
404
|
from hdfe_stream.core.utils import as_lazy
|
|
@@ -446,6 +450,13 @@ def _fit_mixed(fml, data, *, case, panel=None, random, draws=500, halton=True, s
|
|
|
446
450
|
kind[k], ridx[k] = DISTRIBUTIONS[dist], len(rnames)
|
|
447
451
|
rnames.append(nm)
|
|
448
452
|
|
|
453
|
+
if profile:
|
|
454
|
+
from .profile import profile_model
|
|
455
|
+
return profile_model("mixlogit", spec, lf, data, case=case, panel=panel if own_panel else None,
|
|
456
|
+
storage=storage, dtype=dtype, block_mb=block_mb, chunk_mb=chunk_mb,
|
|
457
|
+
n_random=len(rnames), draws=int(draws),
|
|
458
|
+
draws_cache_mb=draws_cache_mb,
|
|
459
|
+
budget=MemoryBudget(max_memory_gb, memory_reserve))
|
|
449
460
|
t0 = time.time()
|
|
450
461
|
log(f"mixed logit: {spec.fml}")
|
|
451
462
|
run = Run(_base_dir(workdir), auto_cleanup=not keep_intermediates)
|
|
@@ -35,6 +35,7 @@ from dataclasses import replace
|
|
|
35
35
|
import numba as nb
|
|
36
36
|
import numpy as np
|
|
37
37
|
|
|
38
|
+
from ._limits import limited
|
|
38
39
|
from .optimize import _GradientDriver
|
|
39
40
|
|
|
40
41
|
# --------------------------------------------------------------------------
|
|
@@ -469,13 +470,16 @@ class _Tree:
|
|
|
469
470
|
# fitting
|
|
470
471
|
# --------------------------------------------------------------------------
|
|
471
472
|
|
|
473
|
+
@limited
|
|
472
474
|
def _fit_nested(fml, data, *, case, alt, nests, vcov="iid", cluster=(), weights=None,
|
|
473
475
|
weights_type="aweights", offset=None, workdir=None, tol=1e-8, maxiter=200,
|
|
474
476
|
storage="disk", dtype="float64", block_mb=64.0, polars_engine="cpu",
|
|
475
477
|
keep_intermediates=False, verbose=True, logger=None, log_level=None,
|
|
476
|
-
device="cpu"
|
|
478
|
+
device="cpu", profile=False, chunk_mb=64.0, max_memory_gb=None,
|
|
479
|
+
memory_reserve=None):
|
|
477
480
|
import polars as pl
|
|
478
481
|
from hdfe_stream.core.report import warn
|
|
482
|
+
from hdfe_stream.core.resources import MemoryBudget
|
|
479
483
|
from hdfe_stream.core.store import PolarsEngine
|
|
480
484
|
from hdfe_stream.core.utils import as_lazy
|
|
481
485
|
from hdfe_stream.core.workspace import Run
|
|
@@ -523,6 +527,13 @@ def _fit_nested(fml, data, *, case, alt, nests, vcov="iid", cluster=(), weights=
|
|
|
523
527
|
if K == 0:
|
|
524
528
|
raise ValueError(f"{spec.fml}: the nested logit needs covariates")
|
|
525
529
|
|
|
530
|
+
if profile:
|
|
531
|
+
from .profile import profile_model
|
|
532
|
+
return profile_model("nlogit", spec, lf, data, weights=weights, clusters=clusters,
|
|
533
|
+
requests=requests, case=case, storage=storage, dtype=dtype,
|
|
534
|
+
block_mb=block_mb, chunk_mb=chunk_mb, offset=offset,
|
|
535
|
+
n_lambda=tree.n_lambda, n_alt=len(seen),
|
|
536
|
+
budget=MemoryBudget(max_memory_gb, memory_reserve))
|
|
526
537
|
t0 = time.time()
|
|
527
538
|
log(f"nested logit: {spec.fml}")
|
|
528
539
|
run = Run(_base_dir(workdir), auto_cleanup=not keep_intermediates)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""`profile=True`: the memory and disk a fit needs, from one scan.
|
|
2
|
+
|
|
3
|
+
The size of everything a Newton fit holds is set by P, the number of
|
|
4
|
+
parameters, and P depends on the model and on the number of outcome levels J,
|
|
5
|
+
which only the data can say (a multinomial logit has K x (J - 1) parameters, an
|
|
6
|
+
ordered one K + J - 1, a conditional one K). So the scan counts the outcome's
|
|
7
|
+
distinct values (and the choice sets, fixed-effect levels and clusters), the
|
|
8
|
+
formula gives K, and the arrays are those the drivers allocate: the dense
|
|
9
|
+
P x P information (and a per-thread copy of it in the numba kernels), the
|
|
10
|
+
absorbed fixed effects' cross blocks (G x J x P), the clusters' score sums, and
|
|
11
|
+
the block store.
|
|
12
|
+
|
|
13
|
+
Not modeled, and said so in the profile's warnings: random intercepts (their
|
|
14
|
+
EM holds group-level arrays in memory) and the mixed and nested logit's
|
|
15
|
+
per-evaluation workspaces beyond the per-thread P x P accumulators.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import numba
|
|
21
|
+
import numpy as np
|
|
22
|
+
import polars as pl
|
|
23
|
+
from hdfe_stream.core.profile import (
|
|
24
|
+
GB, Component, Profile, as_components, baseline_gb, combine_profiles, scan_source,
|
|
25
|
+
source_row_group_rows,
|
|
26
|
+
)
|
|
27
|
+
from hdfe_stream.core.resources import MemoryBudget
|
|
28
|
+
|
|
29
|
+
__all__ = ["profile_model", "profile_multi_fe", "combine_profiles"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _roots(expr):
|
|
33
|
+
try:
|
|
34
|
+
return list(expr.meta.root_names())
|
|
35
|
+
except Exception: # noqa: BLE001
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def profile_model(kind, spec, lf, source, *, weights=None, clusters=None, requests=(),
|
|
40
|
+
case=None, panel=None, levels=None, storage="disk", dtype="float64",
|
|
41
|
+
block_mb=64.0, chunk_mb=64.0, offset=None, random=None, n_lambda=0,
|
|
42
|
+
n_random=0, draws=0, draws_cache_mb=0.0, n_alt=0, firth=False,
|
|
43
|
+
n_threads=None, budget=None) -> Profile:
|
|
44
|
+
"""The `Profile` of one model of `kind` (``"mlogit"``, ``"ologit"``,
|
|
45
|
+
``"clogit"``, ``"cfelogit"``, ``"nlogit"``, ``"mixlogit"``)."""
|
|
46
|
+
budget = budget if budget is not None else MemoryBudget()
|
|
47
|
+
clusters = clusters or {}
|
|
48
|
+
case = list(case or [])
|
|
49
|
+
panel = list(panel or [])
|
|
50
|
+
fe = list(spec.fe)
|
|
51
|
+
fe_cols = {d: d.split("^") for d in fe}
|
|
52
|
+
K = len(spec.x)
|
|
53
|
+
nt = n_threads or numba.get_num_threads()
|
|
54
|
+
pl_threads = pl.thread_pool_size()
|
|
55
|
+
isz = 4 if dtype == "float32" else 8
|
|
56
|
+
warns: list[str] = []
|
|
57
|
+
if random:
|
|
58
|
+
warns.append("random intercepts: the EM's group-level arrays are not modeled")
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------- scan
|
|
61
|
+
raw = list(dict.fromkeys([c for cs in clusters.values() for c in cs]
|
|
62
|
+
+ [c for cs in fe_cols.values() for c in cs] + case + panel))
|
|
63
|
+
present = [spec.y, *[ex for _, ex in spec.x]] + [pl.col(c) for c in raw]
|
|
64
|
+
if weights:
|
|
65
|
+
present.append(pl.col(weights).cast(pl.Float64))
|
|
66
|
+
if offset is not None:
|
|
67
|
+
present.append(offset)
|
|
68
|
+
distinct = {}
|
|
69
|
+
ycols = _roots(spec.y)
|
|
70
|
+
if kind in ("mlogit", "ologit") and levels is None and ycols:
|
|
71
|
+
distinct["__y"] = ycols[:1]
|
|
72
|
+
for name, cols in clusters.items():
|
|
73
|
+
distinct[f"cl:{name}"] = cols
|
|
74
|
+
for d, cols in fe_cols.items():
|
|
75
|
+
distinct[f"fe:{d}"] = cols
|
|
76
|
+
if case:
|
|
77
|
+
distinct["__case"] = case
|
|
78
|
+
if panel:
|
|
79
|
+
distinct["__panel"] = panel
|
|
80
|
+
if kind == "nlogit" and n_alt:
|
|
81
|
+
pass
|
|
82
|
+
stats = scan_source(lf, present=present, distinct=distinct)
|
|
83
|
+
N = max(stats.n_rows, 1)
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------- model dimensions
|
|
86
|
+
J = len(levels) if levels is not None else max(stats.distinct.get("__y", 2), 2)
|
|
87
|
+
if kind == "mlogit":
|
|
88
|
+
P = K * (J - 1)
|
|
89
|
+
m = J
|
|
90
|
+
elif kind == "ologit":
|
|
91
|
+
P = K + J - 1
|
|
92
|
+
m = 1
|
|
93
|
+
elif kind == "nlogit":
|
|
94
|
+
P = K + n_lambda
|
|
95
|
+
m = 1
|
|
96
|
+
elif kind == "mixlogit":
|
|
97
|
+
P = K + n_random
|
|
98
|
+
m = 1
|
|
99
|
+
else: # clogit, cfelogit
|
|
100
|
+
P = K
|
|
101
|
+
m = 1
|
|
102
|
+
n_cases = stats.distinct.get("__case", 0)
|
|
103
|
+
src = len(set(_roots(spec.y)) | {c for _, e in spec.x for c in _roots(e)} | set(raw)
|
|
104
|
+
| ({weights} if weights else set()))
|
|
105
|
+
ncodes = len(clusters) + len(fe) + (1 if case else 0)
|
|
106
|
+
width_store = K * isz + 8 * (1 + bool(weights) + (offset is not None)) + 4 * ncodes + 8
|
|
107
|
+
comps: list[Component] = []
|
|
108
|
+
add = comps.append
|
|
109
|
+
|
|
110
|
+
# -------------------------------------------------------- pass 0 / store
|
|
111
|
+
rows_blk = max(int(block_mb * 1e6 / max(8 * K, 1)), 1)
|
|
112
|
+
add(Component("kernels and library state (first fit)", 0.6e9, 1.0e9,
|
|
113
|
+
formula="the floor of a small fit: numba kernels, Polars, scipy"))
|
|
114
|
+
add(Component("pass 0: Polars sink buffers", 0.25 * pl_threads * 500_000 * src * 8,
|
|
115
|
+
pl_threads * 500_000 * src * 8, phases=("pass0",),
|
|
116
|
+
formula=f"{pl_threads} threads x ~one row group x {src} source columns x 8"))
|
|
117
|
+
add(Component("pass 0: design batches", min(rows_blk, N) * (K + 4) * 8 * 2,
|
|
118
|
+
min(rows_blk, N) * (K + 4) * 8 * 4, phases=("pass0",),
|
|
119
|
+
formula=f"min(block_mb rows={rows_blk:,}, N) x (K={K} + 4) x 8 x 2-4"))
|
|
120
|
+
add(Component("pass 0: moments and collection", N * 8 * 2, N * 8 * 6, phases=("pass0",),
|
|
121
|
+
formula="outcome / choice-set / group bookkeeping, 2-6 x N x 8"))
|
|
122
|
+
add(Component("sample file (narrow source columns)", 0.7 * N * src * 8, N * src * 8,
|
|
123
|
+
where="disk", formula=f"N x {src} source columns x 8, compressed"))
|
|
124
|
+
add(Component("block store", N * width_store, N * width_store,
|
|
125
|
+
where="ram" if storage == "memory" else "disk",
|
|
126
|
+
formula=f"N x ({K} x {isz} + ...) = {width_store} bytes per row "
|
|
127
|
+
f"(storage={storage!r})"))
|
|
128
|
+
if case:
|
|
129
|
+
add(Component("choice-set index", n_cases * 16, n_cases * 24, phases=("pass0", "newton"),
|
|
130
|
+
formula="cases x 16-24 bytes"))
|
|
131
|
+
|
|
132
|
+
# --------------------------------------------------------- the fit
|
|
133
|
+
add(Component("block in flight", block_mb * 1e6 * 1.5, block_mb * 1e6 * 3,
|
|
134
|
+
phases=("newton",), formula="1.5-3 x block_mb"))
|
|
135
|
+
add(Component("chunk scratch (scores, linear predictors)", chunk_mb * 1e6 * 2,
|
|
136
|
+
chunk_mb * 1e6 * 4, phases=("newton",), formula="2-4 x chunk_mb"))
|
|
137
|
+
dense = P * P * 8
|
|
138
|
+
per_thread = kind in ("mlogit", "cfelogit", "clogit", "mixlogit", "nlogit")
|
|
139
|
+
add(Component("information matrix, factors, steps", 4 * dense, 6 * dense,
|
|
140
|
+
phases=("newton",), formula=f"4-6 x P={P:,}^2 x 8"))
|
|
141
|
+
if per_thread and (kind != "mlogit" or P <= 1200):
|
|
142
|
+
add(Component("per-thread information accumulators", nt * dense * (1 if kind == "mlogit"
|
|
143
|
+
else 1),
|
|
144
|
+
nt * dense * 2, phases=("newton",),
|
|
145
|
+
formula=f"nthreads={nt} x P^2 x 8 (sparse / unit-wise kernels)"))
|
|
146
|
+
if kind in ("mixlogit", "nlogit"):
|
|
147
|
+
nb_ = P + 2
|
|
148
|
+
add(Component("outer-product (BHHH) accumulators", nt * nb_ * dense, nt * nb_ * dense * 2,
|
|
149
|
+
phases=("newton",), formula=f"nthreads x ~(P+2) x P^2 x 8"))
|
|
150
|
+
if kind == "mixlogit" and draws:
|
|
151
|
+
cache = min(draws_cache_mb * 1e6, (stats.distinct.get("__panel", n_cases) or 1)
|
|
152
|
+
* draws * max(n_random, 1) * 8)
|
|
153
|
+
add(Component("mixed logit: cached draws", cache, draws_cache_mb * 1e6, phases=("newton",),
|
|
154
|
+
formula="min(draws_cache_mb, panels x draws x random x 8)"))
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------- absorbed fixed effect
|
|
157
|
+
for d in fe:
|
|
158
|
+
if random:
|
|
159
|
+
continue
|
|
160
|
+
G = max(stats.distinct.get(f"fe:{d}", 1), 1)
|
|
161
|
+
cross = G * m * P * 8
|
|
162
|
+
add(Component(f"fixed effect {d}: level counts", 3 * G * m * 8, 3 * G * m * 8,
|
|
163
|
+
phases=("newton",), formula=f"3 x G={G:,} x {m} x 8"))
|
|
164
|
+
add(Component(f"fixed effect {d}: cross blocks", cross, 2 * cross, phases=("newton",),
|
|
165
|
+
formula=f"G x {m} x P={P:,} x 8 (x2 in the check)"))
|
|
166
|
+
if not budget.fits(2 * cross / GB):
|
|
167
|
+
warns.append(f"fixed effect {d}: the cross blocks need {2 * cross / GB:.1f} GB and "
|
|
168
|
+
f"{budget.headroom_gb():.1f} GB is free; the fit will raise "
|
|
169
|
+
"MemoryError (use fewer parameters, a coarser effect, or a larger "
|
|
170
|
+
"max_memory_gb)")
|
|
171
|
+
|
|
172
|
+
# --------------------------------------------------------------- clusters
|
|
173
|
+
for name, cols in clusters.items():
|
|
174
|
+
Gc = max(stats.distinct.get(f"cl:{name}", 1), 1)
|
|
175
|
+
add(Component(f"cluster scores: {name}", Gc * P * 8, Gc * P * 8, phases=("newton",),
|
|
176
|
+
formula=f"G={Gc:,} x P x 8"))
|
|
177
|
+
for r in requests:
|
|
178
|
+
if r.startswith("CRV3:") and r.split(":", 1)[1] == name.split(":", 1)[-1]:
|
|
179
|
+
need = Gc * P * P * 8
|
|
180
|
+
add(Component(f"CRV3 cluster information: {name}", need, need,
|
|
181
|
+
phases=("newton",), formula=f"G x P^2 x 8"))
|
|
182
|
+
if not budget.fits(need / GB):
|
|
183
|
+
warns.append(f"CRV3 by {name}: {need / GB:.1f} GB and "
|
|
184
|
+
f"{budget.headroom_gb():.1f} GB is free; the fit raises "
|
|
185
|
+
"MemoryError (use CRV1, or a larger max_memory_gb)")
|
|
186
|
+
|
|
187
|
+
return Profile(
|
|
188
|
+
model=f"{kind}: {spec.fml}", components=as_components(comps), scan=stats,
|
|
189
|
+
threads=int(nt), polars_threads=int(pl_threads), baseline_gb=baseline_gb(),
|
|
190
|
+
memory_limit_gb=budget.limit_gb, memory_reserve=budget.reserve, warnings=warns,
|
|
191
|
+
notes={"P": P, "K": K, "J": J if kind in ("mlogit", "ologit") else None,
|
|
192
|
+
"rows": N, "cases": n_cases or None})
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def profile_multi_fe(spec, lf, source, *, weights=None, weights_type="aweights", clusters=None,
|
|
196
|
+
requests=(), hetero=False, vkey="iid", levels=None, options=None,
|
|
197
|
+
budget=None, n_threads=None, tol=1e-8, maxiter=50) -> Profile:
|
|
198
|
+
"""The profile of the multinomial logit with several absorbed fixed effects,
|
|
199
|
+
which is fitted as a Poisson regression (`fepois_stream`) on the data
|
|
200
|
+
expanded J-fold (see `multi_fe`): one row per observation and outcome level,
|
|
201
|
+
a fixed effect per observation, each fixed effect interacted with the level,
|
|
202
|
+
each covariate with the non-base levels.
|
|
203
|
+
|
|
204
|
+
That regression is hdfe-stream's, so this is hdfe-stream's profile of it,
|
|
205
|
+
from counts worked out here: N x J rows, N groups of J rows (the observation
|
|
206
|
+
fixed effect), G x J levels for each fixed effect, K x (J - 1) covariates.
|
|
207
|
+
The scan reads the *original* data once; nothing is expanded."""
|
|
208
|
+
import hdfe_stream as hs
|
|
209
|
+
|
|
210
|
+
from .multi_fe import _CNT, _LVL, _OBS, _W
|
|
211
|
+
|
|
212
|
+
budget = budget if budget is not None else MemoryBudget()
|
|
213
|
+
clusters = clusters or {}
|
|
214
|
+
xs = [(nm, ex) for nm, ex in spec.x if nm != "Intercept"]
|
|
215
|
+
if not xs:
|
|
216
|
+
raise ValueError(f"{spec.fml}: the multinomial logit needs covariates")
|
|
217
|
+
K = len(xs)
|
|
218
|
+
fe_cols = {d: d.split("^") for d in spec.fe}
|
|
219
|
+
cl_cols = sorted({c for cols in clusters.values() for c in cols})
|
|
220
|
+
fe_src = list(dict.fromkeys(c for cols in fe_cols.values() for c in cols))
|
|
221
|
+
|
|
222
|
+
# ---- one scan of the original data: N, the outcome's levels, groups, clusters
|
|
223
|
+
present = [spec.y, *[ex for _, ex in xs]] + [pl.col(c) for c in dict.fromkeys(fe_src + cl_cols)]
|
|
224
|
+
if weights:
|
|
225
|
+
present.append(pl.col(weights).cast(pl.Float64))
|
|
226
|
+
distinct = {}
|
|
227
|
+
ycols = _roots(spec.y)
|
|
228
|
+
if levels is None and ycols:
|
|
229
|
+
distinct["__y"] = ycols[:1]
|
|
230
|
+
for d, cols in fe_cols.items():
|
|
231
|
+
distinct[f"fe:{d}"] = cols
|
|
232
|
+
for cols in clusters.values():
|
|
233
|
+
distinct["^".join(cols)] = cols
|
|
234
|
+
stats = scan_source(lf, present=present, distinct=distinct)
|
|
235
|
+
N = max(stats.n_rows, 1)
|
|
236
|
+
J = len(levels) if levels is not None else max(stats.distinct.get("__y", 2), 2)
|
|
237
|
+
|
|
238
|
+
# ---- the expanded problem's counts
|
|
239
|
+
fe_names = [_OBS, *[f"{d}^{_LVL}" for d in spec.fe]]
|
|
240
|
+
xcols = [f"__ls_x{k}_{j}" for j in range(1, J) for k in range(K)]
|
|
241
|
+
exp_distinct = {_OBS: N, "__cells": N * J}
|
|
242
|
+
for d in spec.fe:
|
|
243
|
+
exp_distinct[f"{d}^{_LVL}"] = max(stats.distinct.get(f"fe:{d}", 1), 1) * J
|
|
244
|
+
for cols in clusters.values():
|
|
245
|
+
key = "^".join(cols)
|
|
246
|
+
exp_distinct[key] = stats.distinct.get(key, N)
|
|
247
|
+
from hdfe_stream.core.profile import ScanStats
|
|
248
|
+
exp = ScanStats(n_raw=stats.n_raw * J, n_rows=N * J, distinct=exp_distinct,
|
|
249
|
+
seconds=stats.seconds)
|
|
250
|
+
n_src = 1 + len(fe_src) + len(cl_cols) + 1 + len(xcols) + 1 + (1 if weights else 0)
|
|
251
|
+
|
|
252
|
+
hkey = {"iid": "iid", "hetero": f"CRV1:{_OBS}"}
|
|
253
|
+
to_h = {k: hkey.get(k, k) for k in ["iid", *(["hetero"] if hetero else []), *requests]}
|
|
254
|
+
vcov_h = to_h[vkey]
|
|
255
|
+
extra = [v.split(":", 1)[1] for k, v in to_h.items() if v != vcov_h and k != "iid"]
|
|
256
|
+
opts = {k: v for k, v in (options or {}).items() if k not in ("device", "polars_engine")}
|
|
257
|
+
est = hs.StreamingGLM(
|
|
258
|
+
y=[(_CNT, pl.col(_CNT))], x=[(c, pl.col(c)) for c in xcols], fe=fe_names,
|
|
259
|
+
family="poisson", weights=_W if weights else None, weights_type=weights_type,
|
|
260
|
+
keep=[_LVL, *([_W] if weights else [])], n_threads=n_threads,
|
|
261
|
+
max_memory_gb=budget.limit_gb, memory_reserve=budget.reserve, **opts)
|
|
262
|
+
prof = est._profile_from_stats(exp, source, vcov_h, extra, None, n_src=n_src)
|
|
263
|
+
prof.model = f"mlogit (several fixed effects, as a Poisson regression): {spec.fml}"
|
|
264
|
+
prof.warnings.insert(0, f"fitted by fepois_stream on the data expanded J={J}-fold "
|
|
265
|
+
f"({N * J:,} rows, {len(xcols)} covariates, one fixed effect per "
|
|
266
|
+
"observation): this is hdfe-stream's profile of that regression, "
|
|
267
|
+
"from counts worked out from one scan of the original data")
|
|
268
|
+
prof.notes.update({"route": "poisson", "J": J, "K": K, "rows_original": N,
|
|
269
|
+
"expanded_rows": N * J, "scan_original": stats.to_dict()})
|
|
270
|
+
return prof
|
|
@@ -11,8 +11,9 @@ import numba as nb
|
|
|
11
11
|
import numpy as np
|
|
12
12
|
import polars as pl
|
|
13
13
|
from hdfe_stream.core.report import warn
|
|
14
|
+
from hdfe_stream.core.resources import MemoryBudget
|
|
14
15
|
|
|
15
|
-
from ..fe import
|
|
16
|
+
from ..fe import _group_sum, _mnl_group_info, _ordered_info_derivs, _segments
|
|
16
17
|
from ..models import _gram, _mnl_info_sparse
|
|
17
18
|
from ..newton import _chunks, _design, _Driver
|
|
18
19
|
from ..store import CODE
|
|
@@ -41,7 +42,9 @@ class _RandomEffects(_SigmaMixin):
|
|
|
41
42
|
"""Random intercepts at one or more groupings; see the module
|
|
42
43
|
docstring. Plays the role of `_AbsorbedFE` for the Newton driver."""
|
|
43
44
|
|
|
44
|
-
def __init__(self, model, p0, randoms, fixed, keys, log, logger=None, cov="unstructured"
|
|
45
|
+
def __init__(self, model, p0, randoms, fixed, keys, log, logger=None, cov="unstructured",
|
|
46
|
+
budget=None):
|
|
47
|
+
budget = budget if budget is not None else MemoryBudget()
|
|
45
48
|
self.model, self.p0, self.store, self.logger = model, p0, p0.store, logger
|
|
46
49
|
self.cov = cov
|
|
47
50
|
J = model.J
|
|
@@ -169,9 +172,10 @@ class _RandomEffects(_SigmaMixin):
|
|
|
169
172
|
G_all = sum(f.G for f in self.factors)
|
|
170
173
|
gb = (G_all * self.m * max(model.P, 1) * 16
|
|
171
174
|
+ (U * (self.m ** 2 + self.d ** 3) * 8 if self.kind == "crossed" else 0)) / 1e9
|
|
172
|
-
if gb
|
|
173
|
-
raise MemoryError(f"random intercepts need about {gb:.1f} GB for their blocks
|
|
174
|
-
"
|
|
175
|
+
if not budget.fits(gb):
|
|
176
|
+
raise MemoryError(f"random intercepts need about {gb:.1f} GB for their blocks and "
|
|
177
|
+
f"{budget.headroom_gb():.1f} GB is free ({budget}); fit with fewer "
|
|
178
|
+
"covariates, coarser groupings, or a larger max_memory_gb")
|
|
175
179
|
if self.kind == "tree":
|
|
176
180
|
self._tree_setup()
|
|
177
181
|
for f in self.random:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: logit-stream
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Out-of-core logit models by streamed Newton: binary, multinomial, ordered and conditional logit, with absorbed fixed effects
|
|
5
5
|
Author: Lee Tucker
|
|
6
6
|
License-Expression: MIT
|
|
@@ -25,7 +25,8 @@ Classifier: Typing :: Typed
|
|
|
25
25
|
Requires-Python: >=3.10
|
|
26
26
|
Description-Content-Type: text/markdown
|
|
27
27
|
License-File: LICENSE.md
|
|
28
|
-
Requires-Dist: hdfe-stream[formula]
|
|
28
|
+
Requires-Dist: hdfe-stream[formula]>=0.8.0
|
|
29
|
+
Requires-Dist: stream-limits>=0.1.1
|
|
29
30
|
Requires-Dist: polars!=1.41.*,>=1.37
|
|
30
31
|
Requires-Dist: numpy>=1.24
|
|
31
32
|
Requires-Dist: numba>=0.59
|
|
@@ -7,6 +7,7 @@ pyproject.toml
|
|
|
7
7
|
logit_stream/__init__.py
|
|
8
8
|
logit_stream/_choice.py
|
|
9
9
|
logit_stream/_frontend.py
|
|
10
|
+
logit_stream/_limits.py
|
|
10
11
|
logit_stream/api.py
|
|
11
12
|
logit_stream/estimator.py
|
|
12
13
|
logit_stream/fe.py
|
|
@@ -16,6 +17,7 @@ logit_stream/multi_fe.py
|
|
|
16
17
|
logit_stream/nested.py
|
|
17
18
|
logit_stream/newton.py
|
|
18
19
|
logit_stream/optimize.py
|
|
20
|
+
logit_stream/profile.py
|
|
19
21
|
logit_stream/py.typed
|
|
20
22
|
logit_stream/results.py
|
|
21
23
|
logit_stream/simulate.py
|
|
@@ -46,6 +48,8 @@ tests/test_conditional.py
|
|
|
46
48
|
tests/test_device.py
|
|
47
49
|
tests/test_firth_and_fit.py
|
|
48
50
|
tests/test_fixed_effects.py
|
|
51
|
+
tests/test_headroom.py
|
|
52
|
+
tests/test_index_dtype.py
|
|
49
53
|
tests/test_kernels.py
|
|
50
54
|
tests/test_mixed.py
|
|
51
55
|
tests/test_multi_fe.py
|
|
@@ -54,6 +58,7 @@ tests/test_nested.py
|
|
|
54
58
|
tests/test_offset_start.py
|
|
55
59
|
tests/test_ordered.py
|
|
56
60
|
tests/test_probit.py
|
|
61
|
+
tests/test_profile.py
|
|
57
62
|
tests/test_random.py
|
|
58
63
|
tests/test_simulate.py
|
|
59
64
|
tests/test_weights_scale.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "logit-stream"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Out-of-core logit models by streamed Newton: binary, multinomial, ordered and conditional logit, with absorbed fixed effects"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -30,7 +30,8 @@ requires-python = ">=3.10"
|
|
|
30
30
|
dependencies = [
|
|
31
31
|
# the streaming engine, the result base classes and the formula front end;
|
|
32
32
|
# binary logit is hdfe-stream's feglm_stream
|
|
33
|
-
"hdfe-stream[formula]>=0.
|
|
33
|
+
"hdfe-stream[formula]>=0.8.0", # core.resources and core.profile are new in 0.8.0
|
|
34
|
+
"stream-limits>=0.1.1", # thread and memory limits for jobs on a scheduler (0.1.1: Windows memory)
|
|
34
35
|
"polars>=1.37,!=1.41.*",
|
|
35
36
|
"numpy>=1.24",
|
|
36
37
|
"numba>=0.59",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Memory guards follow the headroom (limit, less a reserve, less what the
|
|
2
|
+
process holds); `max_memory_gb` sets the limit. What cannot shrink is refused
|
|
3
|
+
up front, with the numbers."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import polars as pl
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
import logit_stream as ls
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture(scope="module")
|
|
15
|
+
def df():
|
|
16
|
+
rng = np.random.default_rng(5)
|
|
17
|
+
n = 30_000
|
|
18
|
+
return pl.DataFrame({
|
|
19
|
+
"x1": rng.normal(size=n), "x2": rng.normal(size=n),
|
|
20
|
+
"g": rng.integers(0, 300, n), "y3": rng.integers(0, 3, n)})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_fixed_effect_blocks_that_do_not_fit_are_refused(df):
|
|
24
|
+
ok = ls.mlogit_stream("y3 ~ x1 + x2 | g", df, verbose=False, max_memory_gb=500)
|
|
25
|
+
assert len(ok.coefnames) > 0
|
|
26
|
+
with pytest.raises(MemoryError, match="max_memory_gb"):
|
|
27
|
+
ls.mlogit_stream("y3 ~ x1 + x2 | g", df, verbose=False, max_memory_gb=0.01)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_crv3_is_refused_when_it_does_not_fit(df):
|
|
31
|
+
ok = ls.mlogit_stream("y3 ~ x1 + x2", df, vcov={"CRV3": "g"}, verbose=False,
|
|
32
|
+
max_memory_gb=500)
|
|
33
|
+
assert len(ok.coefnames) > 0
|
|
34
|
+
with pytest.raises(MemoryError, match="max_memory_gb"):
|
|
35
|
+
ls.mlogit_stream("y3 ~ x1 + x2", df, vcov={"CRV3": "g"}, verbose=False,
|
|
36
|
+
max_memory_gb=0.01)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_the_profile_warns_where_the_fit_would_refuse(df):
|
|
40
|
+
p = ls.mlogit_stream("y3 ~ x1 + x2 | g", df, profile=True, verbose=False, max_memory_gb=0.01)
|
|
41
|
+
assert any("MemoryError" in w for w in p.warnings)
|
|
42
|
+
assert p.memory_limit_gb == 0.01
|
|
43
|
+
q = ls.mlogit_stream("y3 ~ x1 + x2 | g", df, profile=True, verbose=False, max_memory_gb=500)
|
|
44
|
+
assert not any("MemoryError" in w for w in q.warnings)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_every_estimator_accepts_the_limit(df):
|
|
48
|
+
rng = np.random.default_rng(6)
|
|
49
|
+
cases, alts = 600, 3
|
|
50
|
+
n = cases * alts
|
|
51
|
+
ch = pl.DataFrame({"case": np.repeat(np.arange(cases), alts), "alt": np.tile(np.arange(alts), cases),
|
|
52
|
+
"x1": rng.normal(size=n), "chosen": rng.integers(0, 2, n).astype("int8")})
|
|
53
|
+
for p in (ls.ologit_stream("y3 ~ x1", df, profile=True, verbose=False, max_memory_gb=8),
|
|
54
|
+
ls.clogit_stream("chosen ~ x1", ch, case="case", profile=True, verbose=False,
|
|
55
|
+
max_memory_gb=8),
|
|
56
|
+
ls.nlogit_stream("chosen ~ x1", ch, case="case", alt="alt",
|
|
57
|
+
nests={"a": [0, 1], "b": [2]}, profile=True, verbose=False,
|
|
58
|
+
max_memory_gb=8),
|
|
59
|
+
ls.mixlogit_stream("chosen ~ x1", ch, case="case", random={"x1": "normal"},
|
|
60
|
+
draws=20, profile=True, verbose=False, max_memory_gb=8)):
|
|
61
|
+
assert p.memory_limit_gb == 8
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Polars' 64-bit runtime (hdfe-stream issue #5): row indices are UInt64.
|
|
2
|
+
|
|
3
|
+
`with_row_index` returns UInt32 on the default runtime and UInt64 with
|
|
4
|
+
`polars-runtime-64`, whatever the size of the frame. hdfe-stream coded levels
|
|
5
|
+
with it, which broke its fits (and so the binary and multi-fixed-effect
|
|
6
|
+
routes here). The runtime is chosen at install time, so the test makes
|
|
7
|
+
`with_row_index` return UInt64 on any install; with that runtime installed
|
|
8
|
+
the patch changes nothing and the test passes all the same.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import polars as pl
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from logit_stream import logit_stream, mlogit_stream
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _widen_row_index(monkeypatch):
|
|
19
|
+
for cls in (pl.DataFrame, pl.LazyFrame):
|
|
20
|
+
original = cls.with_row_index
|
|
21
|
+
|
|
22
|
+
def with_row_index(self, name="index", offset=None, *, _original=original):
|
|
23
|
+
out = _original(self, name, offset)
|
|
24
|
+
return out.with_columns(pl.col(name).cast(pl.UInt64))
|
|
25
|
+
|
|
26
|
+
monkeypatch.setattr(cls, "with_row_index", with_row_index)
|
|
27
|
+
assert pl.DataFrame({"a": [1]}).with_row_index().schema["index"] == pl.UInt64
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _fits(df):
|
|
31
|
+
binary = df.with_columns((pl.col("y") > 1).cast(pl.Int64).alias("b"))
|
|
32
|
+
return {
|
|
33
|
+
"logit_fe": logit_stream("b ~ x1 + x2 | g", binary, verbose=False),
|
|
34
|
+
"mlogit_fe": mlogit_stream("y ~ x1 + x2 | g", df, verbose=False),
|
|
35
|
+
# two fixed effects take the Poisson route through hdfe-stream
|
|
36
|
+
"mlogit_two_fe": mlogit_stream("y ~ x1 + x2 | g + cl", df, verbose=False),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.parametrize("name", ["logit_fe", "mlogit_fe", "mlogit_two_fe"])
|
|
41
|
+
def test_fixed_effect_fits_with_uint64_row_index(fe_data, monkeypatch, name):
|
|
42
|
+
native = _fits(fe_data)[name]
|
|
43
|
+
with monkeypatch.context() as m:
|
|
44
|
+
_widen_row_index(m)
|
|
45
|
+
wide = _fits(fe_data)[name]
|
|
46
|
+
assert wide.coefnames == native.coefnames
|
|
47
|
+
np.testing.assert_allclose(wide.beta, native.beta, rtol=1e-8, atol=1e-10)
|
|
48
|
+
np.testing.assert_allclose(wide.se, native.se, rtol=1e-7)
|
|
49
|
+
assert wide.loglik == pytest.approx(native.loglik, rel=1e-10)
|
|
50
|
+
got = wide.predict(fe_data).collect()
|
|
51
|
+
assert got.height == fe_data.height
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""`profile=True` on every estimator: a `Profile` instead of a fit, P from the
|
|
2
|
+
model and the outcome's levels, and an upper bound that holds."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import polars as pl
|
|
12
|
+
import pytest
|
|
13
|
+
import stream_limits
|
|
14
|
+
from hdfe_stream import Profile
|
|
15
|
+
|
|
16
|
+
import logit_stream as ls
|
|
17
|
+
|
|
18
|
+
# These tests compare the profile with a measured peak, so they need a peak-memory reading (Linux,
|
|
19
|
+
# macOS and, from stream-limits 0.1.1, Windows). The model was calibrated on Linux and checked on
|
|
20
|
+
# macOS; on Windows a failure here is information about the model, not a broken test.
|
|
21
|
+
needs_peak_rss = pytest.mark.skipif(
|
|
22
|
+
stream_limits.peak_rss_gb() is None, reason="peak memory cannot be read on this platform")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(scope="module")
|
|
26
|
+
def df():
|
|
27
|
+
rng = np.random.default_rng(3)
|
|
28
|
+
n = 20_000
|
|
29
|
+
d = pl.DataFrame({f"x{i}": rng.normal(size=n) for i in range(1, 5)})
|
|
30
|
+
return d.with_columns(
|
|
31
|
+
g=pl.Series(rng.integers(0, 400, n)), y5=pl.Series(rng.integers(0, 5, n)),
|
|
32
|
+
d=pl.Series((rng.random(n) < 0.4).astype("int8")))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture(scope="module")
|
|
36
|
+
def choices():
|
|
37
|
+
rng = np.random.default_rng(4)
|
|
38
|
+
cases, alts = 3000, 4
|
|
39
|
+
n = cases * alts
|
|
40
|
+
case = np.repeat(np.arange(cases), alts)
|
|
41
|
+
alt = np.tile(np.arange(alts), cases)
|
|
42
|
+
x1, x2 = rng.normal(size=n), rng.normal(size=n)
|
|
43
|
+
u = 0.8 * x1 - 0.5 * x2 + rng.gumbel(size=n)
|
|
44
|
+
best = u.reshape(cases, alts).argmax(axis=1)
|
|
45
|
+
return pl.DataFrame({"case": case, "alt": alt, "x1": x1, "x2": x2,
|
|
46
|
+
"chosen": (alt == best[case]).astype(np.int8),
|
|
47
|
+
"nest": np.where(alt < 2, "a", "b")})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_parameter_counts_follow_the_model(df):
|
|
51
|
+
m = ls.mlogit_stream("y5 ~ x1 + x2", df, profile=True, verbose=False)
|
|
52
|
+
assert isinstance(m, Profile) and m.notes["P"] == 3 * 4 and m.notes["J"] == 5 # (K + 1) x (J - 1)
|
|
53
|
+
o = ls.ologit_stream("y5 ~ x1 + x2", df, profile=True, verbose=False)
|
|
54
|
+
assert o.notes["P"] == 2 + 4 # K + J - 1
|
|
55
|
+
assert o.memory_gb[0] <= o.memory_gb[1]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_levels_option_skips_the_outcome_scan(df):
|
|
59
|
+
p = ls.mlogit_stream("y5 ~ x1", df, levels=[0, 1, 2], profile=True, verbose=False)
|
|
60
|
+
assert p.notes["J"] == 3
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_no_run_directory_and_no_fit(df, tmp_path):
|
|
64
|
+
ls.mlogit_stream("y5 ~ x1 + x2", df, workdir=tmp_path, profile=True, verbose=False)
|
|
65
|
+
assert list(tmp_path.iterdir()) == []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_fixed_effect_and_cluster_arrays(df):
|
|
69
|
+
p = ls.mlogit_stream("y5 ~ x1 + x2 | g", df, vcov={"CRV1": "g"}, profile=True, verbose=False)
|
|
70
|
+
names = [c.name for c in p.components]
|
|
71
|
+
assert any("cross blocks" in n for n in names) and any(n.startswith("cluster scores") for n in names)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_crv3_is_reported(df):
|
|
75
|
+
p = ls.mlogit_stream("y5 ~ x1 + x2", df, vcov={"CRV3": "g"}, profile=True, verbose=False)
|
|
76
|
+
assert any(c.name.startswith("CRV3") for c in p.components)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_binary_logit_and_probit_use_hdfe_streams_profile(df):
|
|
80
|
+
for f in (ls.logit_stream, ls.probit_stream):
|
|
81
|
+
p = f("d ~ x1 | g", df, profile=True, verbose=False)
|
|
82
|
+
assert isinstance(p, Profile) and "StreamingGLM" in p.model
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_conditional_nested_and_mixed(choices):
|
|
86
|
+
c = ls.clogit_stream("chosen ~ x1 + x2", choices, case="case", profile=True, verbose=False)
|
|
87
|
+
assert c.notes["P"] == 2 and c.notes["cases"] > 2000
|
|
88
|
+
n = ls.nlogit_stream("chosen ~ x1 + x2", choices, case="case", alt="alt",
|
|
89
|
+
nests={"a": [0, 1], "b": [2, 3]}, profile=True, verbose=False)
|
|
90
|
+
assert n.notes["P"] == 2 + 2
|
|
91
|
+
m = ls.mixlogit_stream("chosen ~ x1 + x2", choices, case="case", random={"x1": "normal"},
|
|
92
|
+
draws=50, profile=True, verbose=False)
|
|
93
|
+
assert m.notes["P"] == 3
|
|
94
|
+
u = ls.cfelogit_stream("chosen ~ x1 + x2 | case", choices, profile=True, verbose=False)
|
|
95
|
+
assert isinstance(u, Profile)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_several_fixed_effects_in_mlogit_profile_the_expanded_poisson_fit(df):
|
|
99
|
+
p = ls.mlogit_stream("y5 ~ x1 + x2 | g + y5b", df.with_columns(y5b=pl.col("g") % 7),
|
|
100
|
+
profile=True, verbose=False)
|
|
101
|
+
assert isinstance(p, Profile) and p.notes["route"] == "poisson"
|
|
102
|
+
assert p.notes["J"] == 5 and p.notes["K"] == 2
|
|
103
|
+
assert p.notes["expanded_rows"] == 5 * len(df) and p.scan.n_rows == 5 * len(df)
|
|
104
|
+
assert any("expanded" in w for w in p.warnings)
|
|
105
|
+
# K x (J - 1) covariates in the expanded regression, a group of J rows per observation
|
|
106
|
+
assert p.notes["m"] == 1 + 2 * 4
|
|
107
|
+
clustered = ls.mlogit_stream("y5 ~ x1 + x2 | g + y5b", df.with_columns(y5b=pl.col("g") % 7),
|
|
108
|
+
vcov={"CRV1": "g"}, profile=True, verbose=False)
|
|
109
|
+
assert any(c.name.startswith("cluster scores") or c.name.startswith("CRV")
|
|
110
|
+
for c in clustered.components) or clustered.memory_gb[1] >= p.memory_gb[1] - 0.05
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_threads_scale_the_per_thread_terms(df):
|
|
114
|
+
a = ls.mlogit_stream("y5 ~ x1 + x2", df, profile=True, verbose=False, n_threads=1)
|
|
115
|
+
b = ls.mlogit_stream("y5 ~ x1 + x2", df, profile=True, verbose=False, n_threads=4)
|
|
116
|
+
# the request is capped at the machine's cores (a 3-core CI runner gives 3)
|
|
117
|
+
assert a.threads == 1 and 1 < b.threads <= 4
|
|
118
|
+
pt = lambda p: sum(c.expected for c in p.components if c.name.startswith("per-thread"))
|
|
119
|
+
assert pt(b) == pytest.approx(b.threads * pt(a))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_validation_still_happens(df):
|
|
123
|
+
with pytest.raises(ValueError, match="storage"):
|
|
124
|
+
ls.mlogit_stream("y5 ~ x1", df, storage="tape", profile=True, verbose=False)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
MEASURE = r'''
|
|
128
|
+
import json, sys, numpy as np, polars as pl
|
|
129
|
+
import logit_stream as ls
|
|
130
|
+
fn, fml, kw = sys.argv[1], sys.argv[2], json.loads(sys.argv[3])
|
|
131
|
+
rng = np.random.default_rng(0); n = 300_000
|
|
132
|
+
df = pl.DataFrame({f"x{i}": rng.normal(size=n) for i in range(1, 5)}).with_columns(
|
|
133
|
+
g=pl.Series(rng.integers(0, 3000, n)), y5=pl.Series(rng.integers(0, 5, n)),
|
|
134
|
+
h=pl.Series(rng.integers(0, 12, n)))
|
|
135
|
+
f = getattr(ls, fn)
|
|
136
|
+
p = f(fml, df, profile=True, verbose=False, **kw)
|
|
137
|
+
r = f(fml, df, verbose=False, **kw)
|
|
138
|
+
print(json.dumps({"mem": p.memory_gb, "rss": r.diagnostics["peak_rss_gb"],
|
|
139
|
+
"disk": p.disk_gb, "disk_peak": r.diagnostics.get("disk_peak_gb")}))
|
|
140
|
+
'''
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@needs_peak_rss
|
|
144
|
+
@pytest.mark.parametrize("fn,fml,kw", [
|
|
145
|
+
("mlogit_stream", "y5 ~ x1 + x2 + x3", {}),
|
|
146
|
+
("mlogit_stream", "y5 ~ x1 + x2 | g", {}),
|
|
147
|
+
("mlogit_stream", "y5 ~ x1 + x2", {"vcov": {"CRV1": "g"}}),
|
|
148
|
+
("ologit_stream", "y5 ~ x1 + x2 + x3", {}),
|
|
149
|
+
("mlogit_stream", "y5 ~ x1 + x2 | g + h", {}),
|
|
150
|
+
])
|
|
151
|
+
def test_the_upper_bound_holds(fn, fml, kw):
|
|
152
|
+
out = subprocess.run([sys.executable, "-W", "ignore", "-c", MEASURE, fn, fml, json.dumps(kw)],
|
|
153
|
+
capture_output=True, text=True, timeout=900)
|
|
154
|
+
assert out.returncode == 0, out.stderr[-500:]
|
|
155
|
+
r = json.loads(out.stdout.strip().splitlines()[-1])
|
|
156
|
+
assert r["rss"] <= r["mem"][1]
|
|
157
|
+
# a loose check that the estimate is not wildly pessimistic. A bare process holds less resident
|
|
158
|
+
# memory on Windows than the Linux-calibrated baseline allowance (0.395 of expected was measured)
|
|
159
|
+
assert r["rss"] >= (0.3 if sys.platform == "win32" else 0.4) * r["mem"][0]
|
|
160
|
+
if r["disk_peak"] is not None: # the several-fixed-effects route does not record it
|
|
161
|
+
assert r["disk_peak"] <= r["disk"][1] + 0.01
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|