dpd-stream 0.2.0__tar.gz → 0.2.1__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 (47) hide show
  1. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/CHANGELOG.md +11 -0
  2. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/CITATION.cff +2 -2
  3. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/PKG-INFO +3 -2
  4. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/__init__.py +1 -1
  5. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/store.py +9 -4
  6. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream.egg-info/PKG-INFO +3 -2
  7. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream.egg-info/SOURCES.txt +1 -0
  8. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream.egg-info/requires.txt +2 -1
  9. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/pyproject.toml +5 -2
  10. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/tests/test_features.py +14 -0
  11. dpd_stream-0.2.1/tests/test_resources.py +96 -0
  12. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/LICENSE.md +0 -0
  13. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/MANIFEST.in +0 -0
  14. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/README.md +0 -0
  15. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/_absorb.py +0 -0
  16. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/_blocks.py +0 -0
  17. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/_frontend.py +0 -0
  18. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/_idtests.py +0 -0
  19. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/_nl.py +0 -0
  20. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/_passes.py +0 -0
  21. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/api.py +0 -0
  22. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/estimator.py +0 -0
  23. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/inference.py +0 -0
  24. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/kernels/__init__.py +0 -0
  25. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/kernels/cells.py +0 -0
  26. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/kernels/idtests.py +0 -0
  27. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/kernels/moments.py +0 -0
  28. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/kernels/nl.py +0 -0
  29. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/kernels/panel.py +0 -0
  30. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/layout.py +0 -0
  31. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/profile.py +0 -0
  32. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/py.typed +0 -0
  33. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/results.py +0 -0
  34. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream/simulate.py +0 -0
  35. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream.egg-info/dependency_links.txt +0 -0
  36. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/dpd_stream.egg-info/top_level.txt +0 -0
  37. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/setup.cfg +0 -0
  38. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/tests/test_absorb.py +0 -0
  39. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/tests/test_headroom.py +0 -0
  40. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/tests/test_idtests.py +0 -0
  41. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/tests/test_index_dtype.py +0 -0
  42. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/tests/test_nl.py +0 -0
  43. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/tests/test_oracle.py +0 -0
  44. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/tests/test_profile.py +0 -0
  45. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/tests/test_published.py +0 -0
  46. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/tests/test_pydynpd.py +0 -0
  47. {dpd_stream-0.2.0 → dpd_stream-0.2.1}/tests/test_stata.py +0 -0
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+
5
+ - **Lower memory reading a panel.** Checking that (panel, time) identifies the observations no
6
+ longer holds a set of every row; repeated times are found as the blocks are written, with the
7
+ same error and the same results. On 9.6 million rows the fit's memory no longer rises to
8
+ 1.55 GB at that step, and a fit now stays near 1.1 GB from 10 to 24 million rows.
9
+ - **Memory tests.** `pytest -m resource` fits difference and system GMM on a 24-million-row
10
+ panel under a fixed memory limit; CI runs them on Linux, macOS and Windows. The default
11
+ `pytest` run leaves them out.
12
+ - Requires hdfe-stream 0.9.0 or later, which reads Parquet in less memory.
13
+
3
14
  ## 0.2.0
4
15
 
5
16
  - **Fit resources (CPUs and memory) are constrained to your job's limits automatically.** On a
@@ -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.2.0
11
- date-released: 2026-10-31
10
+ version: 0.2.1
11
+ date-released: 2026-11-05
12
12
  license: MIT
13
13
  repository-code: "https://github.com/leetucker/dpd-stream"
14
14
  url: "https://github.com/leetucker/dpd-stream"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dpd-stream
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Out-of-core dynamic panel GMM: Arellano-Bond difference GMM and Blundell-Bond system GMM, by streamed passes
5
5
  Author: Lee Tucker
6
6
  License-Expression: MIT
@@ -25,7 +25,7 @@ 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.8.0
28
+ Requires-Dist: hdfe-stream[formula]>=0.9.0
29
29
  Requires-Dist: stream-limits>=0.1.1
30
30
  Requires-Dist: polars!=1.41.*,>=1.37
31
31
  Requires-Dist: numpy>=1.24
@@ -44,6 +44,7 @@ Requires-Dist: sphinx-copybutton>=0.5; extra == "docs"
44
44
  Requires-Dist: sphinx-design>=0.6; extra == "docs"
45
45
  Provides-Extra: test
46
46
  Requires-Dist: pytest>=7.0; extra == "test"
47
+ Requires-Dist: stream-limits[testing]>=0.2.1; extra == "test"
47
48
  Requires-Dist: pandas>=2.0; extra == "test"
48
49
  Dynamic: license-file
49
50
 
@@ -40,7 +40,7 @@ from .results import DPDMulti, DPDResult
40
40
  try:
41
41
  __version__ = _version("dpd-stream")
42
42
  except PackageNotFoundError: # pragma: no cover
43
- __version__ = "0.2.0"
43
+ __version__ = "0.2.1"
44
44
 
45
45
  __all__ = [
46
46
  "DPDMulti",
@@ -86,18 +86,18 @@ def build(lf: pl.LazyFrame, bases, panel, *, tdelta=None, cluster=(), weights=No
86
86
  raise ValueError(f"time variable {tvar!r} must be numeric (integer periods), "
87
87
  f"got {schema[tvar]}")
88
88
  lf = lf.filter(pl.col(pid).is_not_null() & pl.col(tvar).is_not_null())
89
+ # (repeated times within a panel are found while the blocks are written,
90
+ # below: counting the distinct (panel, time) pairs here would hold a set
91
+ # of every row in memory)
89
92
  info = engine.collect(lf.select(
90
93
  pl.len().alias("n"), pl.col(tvar).min().alias("tmin"), pl.col(tvar).max().alias("tmax"),
91
- pl.struct(pid, tvar).n_unique().alias("nu"), pl.col(pid).n_unique().alias("np"),
94
+ pl.col(pid).n_unique().alias("np"),
92
95
  (pl.col(tvar) != pl.col(tvar).round(0)).any().alias("frac")), "panel structure")
93
96
  n = int(info["n"][0])
94
97
  if n == 0:
95
98
  raise ValueError("no observations with a panel and a time")
96
99
  if info["frac"][0]:
97
100
  raise ValueError(f"time variable {tvar!r} must hold whole numbers")
98
- if int(info["nu"][0]) != n:
99
- raise ValueError(f"repeated time values within panel: ({pid}, {tvar}) does not "
100
- "identify the observations")
101
101
  tmin, tmax = int(info["tmin"][0]), int(info["tmax"][0])
102
102
  if tdelta is None:
103
103
  tdelta = 1
@@ -194,6 +194,11 @@ def build(lf: pl.LazyFrame, bases, panel, *, tdelta=None, cluster=(), weights=No
194
194
  frame = frame.sort([pid, _T]) # whole panels: sorted by time within each
195
195
  ids = frame[pid]
196
196
  starts = (ids != ids.shift(1)).fill_null(True).to_numpy()
197
+ # a block holds whole panels, so a repeated time is two neighbouring rows
198
+ if ((~starts[1:]) & (np.diff(frame[_T].to_numpy()) == 0)).any():
199
+ sample.unlink(missing_ok=True)
200
+ raise ValueError(f"repeated time values within panel: ({pid}, {tvar}) does not "
201
+ "identify the observations")
197
202
  pcode = np.cumsum(starts).astype(np.int64) - 1 + code
198
203
  code = int(pcode[-1]) + 1
199
204
  B = (frame.select(base_names).to_numpy().astype(np.float64)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dpd-stream
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Out-of-core dynamic panel GMM: Arellano-Bond difference GMM and Blundell-Bond system GMM, by streamed passes
5
5
  Author: Lee Tucker
6
6
  License-Expression: MIT
@@ -25,7 +25,7 @@ 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.8.0
28
+ Requires-Dist: hdfe-stream[formula]>=0.9.0
29
29
  Requires-Dist: stream-limits>=0.1.1
30
30
  Requires-Dist: polars!=1.41.*,>=1.37
31
31
  Requires-Dist: numpy>=1.24
@@ -44,6 +44,7 @@ Requires-Dist: sphinx-copybutton>=0.5; extra == "docs"
44
44
  Requires-Dist: sphinx-design>=0.6; extra == "docs"
45
45
  Provides-Extra: test
46
46
  Requires-Dist: pytest>=7.0; extra == "test"
47
+ Requires-Dist: stream-limits[testing]>=0.2.1; extra == "test"
47
48
  Requires-Dist: pandas>=2.0; extra == "test"
48
49
  Dynamic: license-file
49
50
 
@@ -41,4 +41,5 @@ tests/test_oracle.py
41
41
  tests/test_profile.py
42
42
  tests/test_published.py
43
43
  tests/test_pydynpd.py
44
+ tests/test_resources.py
44
45
  tests/test_stata.py
@@ -1,4 +1,4 @@
1
- hdfe-stream[formula]>=0.8.0
1
+ hdfe-stream[formula]>=0.9.0
2
2
  stream-limits>=0.1.1
3
3
  polars!=1.41.*,>=1.37
4
4
  numpy>=1.24
@@ -20,4 +20,5 @@ sphinx-design>=0.6
20
20
 
21
21
  [test]
22
22
  pytest>=7.0
23
+ stream-limits[testing]>=0.2.1
23
24
  pandas>=2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dpd-stream"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Out-of-core dynamic panel GMM: Arellano-Bond difference GMM and Blundell-Bond system GMM, by streamed passes"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -30,7 +30,7 @@ requires-python = ">=3.10"
30
30
  dependencies = [
31
31
  # the row store, run directories, the formula compiler and the result
32
32
  # base classes (hdfe_stream.core's stable names)
33
- "hdfe-stream[formula]>=0.8.0", # core.resources and core.profile are new in 0.8.0
33
+ "hdfe-stream[formula]>=0.9.0", # 0.9.0: lower-memory Parquet batch reads (iter_groups)
34
34
  "stream-limits>=0.1.1", # thread and memory limits for jobs on a scheduler (0.1.1: Windows memory)
35
35
  "polars>=1.37,!=1.41.*",
36
36
  "numpy>=1.24",
@@ -65,6 +65,7 @@ docs = [
65
65
  ]
66
66
  test = [
67
67
  "pytest>=7.0",
68
+ "stream-limits[testing]>=0.2.1", # resource_runner, for tests/test_resources.py
68
69
  "pandas>=2.0",
69
70
  ]
70
71
 
@@ -76,6 +77,8 @@ include = ["dpd_stream*"]
76
77
 
77
78
  [tool.pytest.ini_options]
78
79
  testpaths = ["tests"]
80
+ # memory tests (tests/test_resources.py) are slow: run them with `pytest -m resource`
81
+ addopts = "-m 'not resource'"
79
82
  filterwarnings = [
80
83
  "ignore::DeprecationWarning",
81
84
  "ignore::FutureWarning",
@@ -59,6 +59,20 @@ def test_fweights_must_be_constant(panel_data):
59
59
  verbose=False)
60
60
 
61
61
 
62
+ @pytest.mark.parametrize("where", ["first", "middle", "last"])
63
+ def test_repeated_time_within_a_panel(panel_data, where):
64
+ """Found while the blocks are written (each holds whole panels, sorted by
65
+ time), in any panel and with the rows in any order; a time shared by two
66
+ panels is not a repeat."""
67
+ ids = panel_data["id"].unique().sort()
68
+ pid = {"first": ids[0], "middle": ids[len(ids) // 2], "last": ids[-1]}[where]
69
+ df = pl.concat([panel_data, panel_data.filter(pl.col("id") == pid).head(1)])
70
+ with pytest.raises(ValueError, match="repeated time values within panel"):
71
+ dpd_stream(FML, df.sample(fraction=1.0, shuffle=True, seed=2), panel=("id", "year"),
72
+ verbose=False, block_mb=0.01)
73
+ assert dpd_stream(FML, panel_data, panel=("id", "year"), verbose=False).n_obs > 0
74
+
75
+
62
76
  def test_iterated(panel_data):
63
77
  two = dpd_stream(FML, panel_data, panel=("id", "year"), verbose=False)
64
78
  it0 = dpd_stream(FML, panel_data, panel=("id", "year"), steps="iterated", maxiter=0,
@@ -0,0 +1,96 @@
1
+ """Memory tests: the streamed fits stay inside a memory limit on data that
2
+ would not fit in it.
3
+
4
+ Run with ``pytest -m resource`` (the default run leaves them out: they take
5
+ about two minutes). Each fit runs in a child process under stream-limits'
6
+ ``resource_runner``: a watchdog on every platform, or a kernel limit when
7
+ ``STREAM_LIMITS_TEST_CGROUP_PARENT`` names a cgroup set up for it.
8
+
9
+ The panel is 3 million firms over 8 years, 24 million rows, 0.96 GB in
10
+ memory, written as six Parquet files and read as a glob. With
11
+ ``max_memory_gb=1.0`` difference and system GMM hold about 1.1 GB of private
12
+ memory (not counting memory-mapped scratch files, which the kernel can
13
+ reclaim), most of it buffers whose sizes are capped: the same on 9.6 million
14
+ rows. The limit, 1.6 GB, leaves 0.5 GB for platform differences; a fit that
15
+ held the panel in memory would need at least 2 GB. Measured on Linux (WSL2,
16
+ 2 threads): difference GMM 1.09 GB, system GMM 1.07 GB.
17
+
18
+ The limit is on private memory, which stream-limits measures on Linux and
19
+ Windows. On macOS it measures resident memory, which counts the mapped
20
+ scratch files, so there the limit is a looser safety net.
21
+ On Windows, private memory is committed memory: a buffer or an allocator
22
+ segment counts in full once allocated, touched or not (an hdfe-stream logit
23
+ fit had 1.61 GB committed while 1.14 GB was resident). Measured in CI
24
+ (GitHub's windows-latest, 2 threads): difference GMM 1.46 GB, system GMM
25
+ 1.38 GB; the limit there is 1.8 GB, which a fit holding the panel would
26
+ still exceed.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import sys
33
+
34
+ import polars as pl
35
+ import pytest
36
+
37
+ from dpd_stream.simulate import simulate_dynamic
38
+
39
+ pytestmark = pytest.mark.resource
40
+
41
+ PARTS, PANELS_PER_PART, T = 6, 500_000, 8 # 24 million rows
42
+ BUDGET_GB = 1.0 # the fit's max_memory_gb
43
+ LIMIT_GB = {"darwin": 2.6, "win32": 1.8}.get(sys.platform, 1.6)
44
+ FML = "y ~ l(y, 1) + x + w | year | gmm(y, 2:4) + gmm(x, 1:3) + iv(w)"
45
+
46
+
47
+ @pytest.fixture(scope="module")
48
+ def big_panel(tmp_path_factory):
49
+ """Simulated in parts (the whole panel at once needs 7 GB to simulate),
50
+ each part's firms numbered after the last part's."""
51
+ folder = tmp_path_factory.mktemp("big")
52
+ size = 0
53
+ for i in range(PARTS):
54
+ d = simulate_dynamic(PANELS_PER_PART, T, seed=100 + i)
55
+ d = d.with_columns(pl.col("id") + i * PANELS_PER_PART)
56
+ d.write_parquet(folder / f"part{i}.parquet")
57
+ size += d.estimated_size()
58
+ assert size > 0.9e9
59
+ return folder / "*.parquet"
60
+
61
+
62
+ def _fit_in_child(resource_runner, system: bool, path, workdir) -> dict:
63
+ code = f"""
64
+ import json
65
+ from dpd_stream import dpd_stream
66
+ r = dpd_stream({FML!r}, {str(path)!r}, panel=("id", "year"), system={system},
67
+ workdir={str(workdir)!r}, verbose=False, max_memory_gb={BUDGET_GB})
68
+ print(json.dumps(dict(n_obs=int(r.n_obs), coef={{k: float(v) for k, v in r.coef().items()}})))
69
+ """
70
+ result = resource_runner([sys.executable, "-c", code], memory_gb=LIMIT_GB,
71
+ threads=2, timeout=900)
72
+ report = {k: result.as_dict()[k] for k in ("status", "mode", "memory_measure",
73
+ "peak_private_bytes", "peak_rss_bytes",
74
+ "elapsed_s")}
75
+ print(json.dumps(report))
76
+ assert result.status == "ok", (report, result.stderr[-3000:])
77
+ if result.kernel:
78
+ assert result.kernel["events"].get("oom_kill", 0) == 0
79
+ return json.loads(result.stdout.strip().splitlines()[-1])
80
+
81
+
82
+ # The checks against the simulated coefficients (simulate_dynamic's defaults:
83
+ # rho 0.5, beta 1) are sanity checks only: agreement with xtabond2 is the rest
84
+ # of the suite's job.
85
+
86
+ def test_difference_gmm_streams_inside_the_limit(resource_runner, big_panel, tmp_path):
87
+ out = _fit_in_child(resource_runner, False, big_panel, tmp_path / "work")
88
+ assert out["n_obs"] == PARTS * PANELS_PER_PART * (T - 2)
89
+ assert abs(out["coef"]["l(y, 1)"] - 0.5) < 0.02
90
+ assert abs(out["coef"]["x"] - 1.0) < 0.02
91
+
92
+
93
+ def test_system_gmm_streams_inside_the_limit(resource_runner, big_panel, tmp_path):
94
+ out = _fit_in_child(resource_runner, True, big_panel, tmp_path / "work")
95
+ assert abs(out["coef"]["l(y, 1)"] - 0.5) < 0.02
96
+ assert abs(out["coef"]["x"] - 1.0) < 0.02
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes