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.
Files changed (66) hide show
  1. {logit_stream-0.1.0 → logit_stream-0.2.0}/CHANGELOG.md +32 -0
  2. {logit_stream-0.1.0 → logit_stream-0.2.0}/CITATION.cff +2 -2
  3. {logit_stream-0.1.0 → logit_stream-0.2.0}/PKG-INFO +3 -2
  4. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/__init__.py +1 -1
  5. logit_stream-0.2.0/logit_stream/_limits.py +31 -0
  6. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/api.py +1 -1
  7. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/estimator.py +37 -33
  8. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/fe.py +7 -6
  9. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/mixed.py +12 -1
  10. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/nested.py +12 -1
  11. logit_stream-0.2.0/logit_stream/profile.py +270 -0
  12. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/random_effects/model.py +9 -5
  13. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream.egg-info/PKG-INFO +3 -2
  14. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream.egg-info/SOURCES.txt +5 -0
  15. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream.egg-info/requires.txt +2 -1
  16. {logit_stream-0.1.0 → logit_stream-0.2.0}/pyproject.toml +3 -2
  17. logit_stream-0.2.0/tests/test_headroom.py +61 -0
  18. logit_stream-0.2.0/tests/test_index_dtype.py +51 -0
  19. logit_stream-0.2.0/tests/test_profile.py +161 -0
  20. {logit_stream-0.1.0 → logit_stream-0.2.0}/LICENSE.md +0 -0
  21. {logit_stream-0.1.0 → logit_stream-0.2.0}/MANIFEST.in +0 -0
  22. {logit_stream-0.1.0 → logit_stream-0.2.0}/README.md +0 -0
  23. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/_choice.py +0 -0
  24. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/_frontend.py +0 -0
  25. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/__init__.py +0 -0
  26. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/_common.py +0 -0
  27. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/fe.py +0 -0
  28. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/meat.py +0 -0
  29. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/mixed.py +0 -0
  30. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/models.py +0 -0
  31. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/nested.py +0 -0
  32. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/random_effects.py +0 -0
  33. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/device/store.py +0 -0
  34. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/models.py +0 -0
  35. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/multi_fe.py +0 -0
  36. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/newton.py +0 -0
  37. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/optimize.py +0 -0
  38. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/py.typed +0 -0
  39. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/random_effects/__init__.py +0 -0
  40. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/random_effects/fit.py +0 -0
  41. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/random_effects/linalg.py +0 -0
  42. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/random_effects/sigma.py +0 -0
  43. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/results.py +0 -0
  44. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/simulate.py +0 -0
  45. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream/store.py +0 -0
  46. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream.egg-info/dependency_links.txt +0 -0
  47. {logit_stream-0.1.0 → logit_stream-0.2.0}/logit_stream.egg-info/top_level.txt +0 -0
  48. {logit_stream-0.1.0 → logit_stream-0.2.0}/setup.cfg +0 -0
  49. {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_api.py +0 -0
  50. {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_cfelogit.py +0 -0
  51. {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_clustering.py +0 -0
  52. {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_conditional.py +0 -0
  53. {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_device.py +0 -0
  54. {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_firth_and_fit.py +0 -0
  55. {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_fixed_effects.py +0 -0
  56. {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_kernels.py +0 -0
  57. {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_mixed.py +0 -0
  58. {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_multi_fe.py +0 -0
  59. {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_multinomial.py +0 -0
  60. {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_nested.py +0 -0
  61. {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_offset_start.py +0 -0
  62. {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_ordered.py +0 -0
  63. {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_probit.py +0 -0
  64. {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_random.py +0 -0
  65. {logit_stream-0.1.0 → logit_stream-0.2.0}/tests/test_simulate.py +0 -0
  66. {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.1.0
11
- date-released: 2026-10-29
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.1.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]<0.7,>=0.6
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
@@ -62,7 +62,7 @@ def _version():
62
62
  from importlib.metadata import version
63
63
  return version("logit-stream")
64
64
  except Exception: # noqa: BLE001 -- not installed
65
- return "0.1.0"
65
+ return "0.2.0"
66
66
 
67
67
 
68
68
  __version__ = _version()
@@ -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``, ``max_s_gb``,
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 _physical_memory_gb():
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
- mem = _physical_memory_gb()
539
- if need > 0.25 * mem:
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 CRV1")
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 > _MAX_GROUP_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); fit with fewer "
207
- "covariates, or a coarser fixed effect")
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 _MAX_GROUP_GB, _group_sum, _mnl_group_info, _ordered_info_derivs, _segments
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 > _MAX_GROUP_GB:
173
- raise MemoryError(f"random intercepts need about {gb:.1f} GB for their blocks; fit "
174
- "with fewer covariates, or coarser groupings")
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.1.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]<0.7,>=0.6
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
@@ -1,4 +1,5 @@
1
- hdfe-stream[formula]<0.7,>=0.6
1
+ hdfe-stream[formula]>=0.8.0
2
+ stream-limits>=0.1.1
2
3
  polars!=1.41.*,>=1.37
3
4
  numpy>=1.24
4
5
  numba>=0.59
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "logit-stream"
7
- version = "0.1.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.6,<0.7",
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