diff-stream 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. diff_stream/__init__.py +52 -0
  2. diff_stream/_bootstrap_worker.py +48 -0
  3. diff_stream/_core/__init__.py +5 -0
  4. diff_stream/_core/contrasts.py +199 -0
  5. diff_stream/_core/covariate_kernels.py +420 -0
  6. diff_stream/_core/cs_kernels.py +536 -0
  7. diff_stream/_core/cs_row_kernels.py +495 -0
  8. diff_stream/_core/design.py +105 -0
  9. diff_stream/_core/etwfe_kernels.py +288 -0
  10. diff_stream/_core/functionals.py +30 -0
  11. diff_stream/_core/multiplier.py +90 -0
  12. diff_stream/_core/nuisance.py +255 -0
  13. diff_stream/_core/partition.py +158 -0
  14. diff_stream/_core/process.py +61 -0
  15. diff_stream/_core/resample.py +171 -0
  16. diff_stream/_core/staging_kernels.py +90 -0
  17. diff_stream/_core/twostage_kernels.py +342 -0
  18. diff_stream/_core/weights.py +26 -0
  19. diff_stream/_core/within.py +81 -0
  20. diff_stream/_docs.py +100 -0
  21. diff_stream/_options.py +62 -0
  22. diff_stream/_present.py +202 -0
  23. diff_stream/absorbed.py +621 -0
  24. diff_stream/aggregation.py +225 -0
  25. diff_stream/api.py +1112 -0
  26. diff_stream/bacon.py +460 -0
  27. diff_stream/bacon_controls.py +188 -0
  28. diff_stream/balance.py +149 -0
  29. diff_stream/bjs.py +101 -0
  30. diff_stream/bjs_placebo.py +167 -0
  31. diff_stream/bootstrap.py +704 -0
  32. diff_stream/cohort_scores.py +68 -0
  33. diff_stream/cs.py +197 -0
  34. diff_stream/cs_unbalanced.py +336 -0
  35. diff_stream/effective_sample.py +73 -0
  36. diff_stream/estimators.py +95 -0
  37. diff_stream/etwfe.py +589 -0
  38. diff_stream/gardner.py +500 -0
  39. diff_stream/linear.py +339 -0
  40. diff_stream/periods.py +72 -0
  41. diff_stream/pretrend.py +97 -0
  42. diff_stream/py.typed +0 -0
  43. diff_stream/reference.py +125 -0
  44. diff_stream/results.py +521 -0
  45. diff_stream/simulate.py +80 -0
  46. diff_stream/stacked.py +405 -0
  47. diff_stream/store.py +706 -0
  48. diff_stream/twfe_store.py +181 -0
  49. diff_stream/twfe_weights.py +552 -0
  50. diff_stream/twostage_absorbed.py +190 -0
  51. diff_stream/unbalanced.py +297 -0
  52. diff_stream-0.1.0.dev0.dist-info/METADATA +192 -0
  53. diff_stream-0.1.0.dev0.dist-info/RECORD +56 -0
  54. diff_stream-0.1.0.dev0.dist-info/WHEEL +5 -0
  55. diff_stream-0.1.0.dev0.dist-info/licenses/LICENSE.md +21 -0
  56. diff_stream-0.1.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,158 @@
1
+ """Bounded whole-group hash partitions, independent of panel/DiD semantics.
2
+
3
+ Consumers implement _leaf and optionally _oversized_group. Rows of a group
4
+ always remain together; the decoded leaf budget can include projected columns.
5
+ Intended for eventual hdfe_stream.core.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ import polars as pl
13
+ import pyarrow.parquet as pq
14
+
15
+
16
+ def preprocess_budget_mb(preprocess_mb, budget):
17
+ """The working memory for partitioning and sorting, in decimal MB.
18
+
19
+ An explicit value is validated and used as given. None (the default) uses
20
+ a quarter of the job's current memory headroom, between 16 MB and 32 GB,
21
+ so a panel that fits in memory is sorted in one piece instead of being
22
+ hash-partitioned through disk, and a smaller ``max_memory_gb`` gives
23
+ smaller partitions automatically. A quarter, not more: headroom shrinks
24
+ while the data are staged, and each leaf is checked against it again.
25
+ """
26
+ if preprocess_mb is None:
27
+ return float(min(32000.0, max(16.0, budget.headroom_gb() * 250)))
28
+ if isinstance(preprocess_mb, bool) or not np.isfinite(preprocess_mb) or preprocess_mb < 1:
29
+ raise ValueError("preprocess_mb must be finite and at least 1 MB, or None")
30
+ return float(preprocess_mb)
31
+
32
+
33
+ def staging_bytes(preprocess_mb, store):
34
+ """Expected memory of staging: the preprocessing allowance, or less when the
35
+ staged rows are small. Sorting a panel in one piece peaked at about six
36
+ times its rows' size in the measurements (more on small panels, where the
37
+ runtime's own use dominates), so eight times plus a fixed 0.4 GB covers
38
+ them; the allowance stays the upper bound."""
39
+ rows = getattr(store, "n_rows_used", None)
40
+ if not rows:
41
+ return preprocess_mb * 1e6
42
+ design = getattr(store, "design", None)
43
+ columns = 4 + (len(design.names) if design is not None else 0)
44
+ columns += int(getattr(store, "weighted", False))
45
+ return min(preprocess_mb * 1e6, 8.0 * rows * 8 * columns + 0.4e9)
46
+
47
+
48
+ @dataclass
49
+ class Part:
50
+ path: Path
51
+ rows: int
52
+ size: int
53
+
54
+
55
+ def whole_group_batches(path, key, max_groups, read_rows=32768):
56
+ """Read sorted Parquet without splitting a group across yielded frames.
57
+
58
+ Memory is bounded by one reader batch, one unfinished group, and the
59
+ consumer's batch. Keys need only be unique within this file.
60
+ """
61
+ pending = None
62
+ with pq.ParquetFile(path, pre_buffer=False) as pf:
63
+ for batch in pf.iter_batches(batch_size=read_rows):
64
+ frame = pl.from_arrow(batch)
65
+ if pending is not None:
66
+ frame = pl.concat([pending, frame])
67
+ values = frame[key].to_numpy()
68
+ starts = np.r_[0, np.flatnonzero(values[1:] != values[:-1]) + 1]
69
+ end = int(starts[-1])
70
+ for j in range(0, len(starts) - 1, max_groups):
71
+ stop = int(starts[min(j + max_groups, len(starts) - 1)])
72
+ yield frame.slice(int(starts[j]), stop - int(starts[j]))
73
+ pending = frame.slice(end)
74
+ if pending is not None:
75
+ yield pending
76
+
77
+
78
+ class GroupPartitions:
79
+ def __init__(self, root, budget, preprocess_mb, *, key="id"):
80
+ self.root = Path(root)
81
+ self.budget = budget
82
+ self.key = key
83
+ self.leaf_bytes = int(preprocess_mb * 1e6 / 4)
84
+ self.read_rows = max(256, min(262144, self.leaf_bytes // 128))
85
+ self.extra_row_bytes = 0
86
+ self.max_leaf_bytes = 0
87
+ self.max_depth = 0
88
+ self.partition_count = 0
89
+ self._serial = 0
90
+
91
+ def _oversized_group(self, part):
92
+ raise MemoryError("a single group exceeds preprocess_mb; increase the preprocessing budget")
93
+
94
+ def _guard(self, size):
95
+ if not self.budget.fits(size / 1e9):
96
+ threads = pl.thread_pool_size()
97
+ hint = (
98
+ f" (Polars is using {threads} threads, and the memory it holds while reading grows "
99
+ "with them, about 130 MB each for row groups of a million rows; set "
100
+ "STREAM_LIMITS_MAX_THREADS or POLARS_MAX_THREADS before Python starts to lower it)"
101
+ if threads > 4
102
+ else ""
103
+ )
104
+ raise MemoryError(
105
+ "preprocessing buffers exceed job headroom; reduce preprocess_mb or request more memory"
106
+ + hint
107
+ )
108
+
109
+ def _name(self, prefix):
110
+ self._serial += 1
111
+ return self.root / f"{prefix}-{self._serial}.parquet"
112
+
113
+ def _prepare(self, part, depth):
114
+ effective = part.size + part.rows * self.extra_row_bytes
115
+ if effective <= self.leaf_bytes:
116
+ self._leaf(part)
117
+ return
118
+ if depth >= 16:
119
+ raise MemoryError("unit/hash skew exceeds the partition budget; increase preprocess_mb")
120
+ self.max_depth = max(self.max_depth, depth + 1)
121
+ fanout = min(16, max(2, int(np.ceil(2 * effective / self.leaf_bytes))))
122
+ children = {}
123
+ writers = {}
124
+ first_id, same_id = None, True
125
+ try:
126
+ with pq.ParquetFile(part.path, pre_buffer=False) as pf:
127
+ for b in pf.iter_batches(batch_size=self.read_rows):
128
+ self._guard(32e6)
129
+ d = pl.from_arrow(b)
130
+ if first_id is None:
131
+ first_id = d[self.key][0]
132
+ same_id = same_id and (d[self.key] == first_id).all()
133
+ d = d.with_columns(
134
+ (pl.col(self.key).hash(seed=72931 + depth) % fanout).alias("bucket")
135
+ )
136
+ for key, frame in d.partition_by("bucket", as_dict=True).items():
137
+ k = key[0]
138
+ table = frame.drop("bucket").to_arrow()
139
+ if k not in writers:
140
+ p = self._name("hash")
141
+ children[k] = Part(p, 0, 0)
142
+ writers[k] = pq.ParquetWriter(
143
+ p, table.schema, compression="zstd", use_dictionary=False
144
+ )
145
+ writers[k].write_table(table, row_group_size=self.read_rows)
146
+ children[k].rows += table.num_rows
147
+ children[k].size += table.nbytes
148
+ finally:
149
+ for w in writers.values():
150
+ w.close()
151
+ part.path.unlink()
152
+ if same_id:
153
+ self._oversized_group(part)
154
+ # Do not retain the parent's last decoded batch and writer metadata
155
+ # while recursively processing its children.
156
+ del writers, d, frame, table, b, w
157
+ for child in children.values():
158
+ self._prepare(child, depth + 1)
@@ -0,0 +1,61 @@
1
+ """Small standard-library process batch, intended for eventual shared core.
2
+
3
+ Threads only wait on child processes; numerical work belongs in the children.
4
+ On failure or interruption, stop and reap children before consumers delete
5
+ their scratch. Commands are argument vectors, never shell strings.
6
+ """
7
+
8
+ import subprocess
9
+ from concurrent.futures import CancelledError, ThreadPoolExecutor
10
+ from threading import Lock
11
+
12
+
13
+ class ProcessBatch:
14
+ def __init__(self, workers):
15
+ self._executor = ThreadPoolExecutor(max_workers=workers)
16
+ self._lock = Lock()
17
+ self._children = set()
18
+ self._closing = False
19
+
20
+ def __enter__(self):
21
+ return self
22
+
23
+ def submit(self, command, log):
24
+ return self._executor.submit(self._run, command, log)
25
+
26
+ def _run(self, command, log):
27
+ with open(log, "wb") as output:
28
+ with self._lock:
29
+ if self._closing:
30
+ raise CancelledError()
31
+ child = subprocess.Popen(
32
+ command, stdin=subprocess.DEVNULL, stdout=output, stderr=subprocess.STDOUT
33
+ )
34
+ self._children.add(child)
35
+ try:
36
+ return child.wait()
37
+ finally:
38
+ with self._lock:
39
+ self._children.discard(child)
40
+
41
+ def __exit__(self, exc_type, exc, tb):
42
+ with self._lock:
43
+ self._closing = True
44
+ children = list(self._children)
45
+ # On normal exit all submitted futures have already completed. A
46
+ # consumer exception/interrupt must not leave numerical fits running.
47
+ for child in children:
48
+ try:
49
+ child.terminate()
50
+ except ProcessLookupError:
51
+ pass
52
+ for child in children:
53
+ try:
54
+ child.wait(timeout=2)
55
+ except subprocess.TimeoutExpired:
56
+ try:
57
+ child.kill()
58
+ except ProcessLookupError:
59
+ pass # Exited between the timeout and kill.
60
+ child.wait()
61
+ self._executor.shutdown(wait=True, cancel_futures=True)
@@ -0,0 +1,171 @@
1
+ """Bounded, disk-backed ordinary whole-group resampling.
2
+
3
+ Generic adapter intended for eventual hdfe_stream.core: consumers own sample
4
+ selection and model semantics. Uniform draws use N independent integer indices;
5
+ copied groups receive distinct draw IDs, rather than frequency weights.
6
+ """
7
+
8
+ import hashlib
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import polars as pl
14
+ import pyarrow.parquet as pq
15
+
16
+ from .partition import GroupPartitions, Part
17
+
18
+
19
+ @dataclass
20
+ class ResampleLeaf:
21
+ path: Path
22
+ start: int
23
+ stop: int
24
+
25
+
26
+ class DiskGroupResampler(GroupPartitions):
27
+ def __init__(
28
+ self,
29
+ snapshot,
30
+ root,
31
+ budget,
32
+ preprocess_mb,
33
+ *,
34
+ key,
35
+ sort_columns,
36
+ select,
37
+ index_column,
38
+ draw_column,
39
+ ):
40
+ super().__init__(root, budget, preprocess_mb, key=key)
41
+ self.sort_columns, self.select = sort_columns, select
42
+ self.index_column, self.draw_column = index_column, draw_column
43
+ self.leaves = []
44
+ self.n_units = self.n_rows = 0
45
+ self.identity = hashlib.sha256()
46
+ size = 0
47
+ with pq.ParquetFile(snapshot, pre_buffer=False) as pf:
48
+ rows = pf.metadata.num_rows
49
+ for batch in pf.iter_batches(batch_size=self.read_rows):
50
+ self._guard(32e6 + batch.nbytes * 3)
51
+ size += batch.nbytes
52
+ self._prepare(Part(Path(snapshot), rows, size), 0)
53
+ self.ends = np.array([leaf.stop for leaf in self.leaves], dtype=np.int64)
54
+
55
+ def _leaf(self, part):
56
+ self._guard(part.size * 4 + 32e6)
57
+ with pq.ParquetFile(part.path, pre_buffer=False) as pf:
58
+ data = pl.from_arrow(pf.read()).sort(self.sort_columns)
59
+ data = self.select(data)
60
+ if len(data):
61
+ ids = data[self.key].to_numpy()
62
+ starts = np.r_[0, np.flatnonzero(ids[1:] != ids[:-1]) + 1]
63
+ lengths = np.diff(np.r_[starts, len(data)])
64
+ for value in ids[starts]:
65
+ encoded = repr(value.item() if isinstance(value, np.generic) else value).encode()
66
+ self.identity.update(len(encoded).to_bytes(8, "little"))
67
+ self.identity.update(encoded)
68
+ data = data.with_columns(
69
+ pl.Series(
70
+ self.index_column,
71
+ np.repeat(np.arange(self.n_units, self.n_units + len(starts)), lengths),
72
+ )
73
+ )
74
+ path = self._name("resample-source")
75
+ data.write_parquet(path, row_group_size=32768)
76
+ self.leaves.append(ResampleLeaf(path, self.n_units, self.n_units + len(starts)))
77
+ self.n_units += len(starts)
78
+ self.n_rows += len(data)
79
+ self.partition_count += 1
80
+ self.max_leaf_bytes = max(self.max_leaf_bytes, part.size)
81
+ part.path.unlink()
82
+
83
+ def materialize(self, rng, root, *, time_column, chunk_rows=32768):
84
+ """Write one exact N-group resample. Return path, dates, rows, plan hash.
85
+
86
+ Integer plans are routed to leaf-specific binary files, never collected
87
+ into an N-row in-memory manifest. Output buffers and join multiplicity
88
+ are bounded separately from the number of groups in the population.
89
+ """
90
+ root = Path(root)
91
+ root.mkdir()
92
+ digest = hashlib.sha256()
93
+ for offset in range(0, self.n_units, chunk_rows):
94
+ count = min(chunk_rows, self.n_units - offset)
95
+ indices = rng.integers(0, self.n_units, size=count, dtype=np.int64)
96
+ digest.update(indices.astype("<i8", copy=False).tobytes())
97
+ buckets = np.searchsorted(self.ends, indices, side="right")
98
+ order = np.argsort(buckets, kind="stable")
99
+ ordered = buckets[order]
100
+ starts = np.r_[0, np.flatnonzero(ordered[1:] != ordered[:-1]) + 1, count]
101
+ for a, b in zip(starts[:-1], starts[1:], strict=True):
102
+ chosen = order[a:b]
103
+ pairs = np.column_stack([indices[chosen], offset + chosen]).astype("<i8")
104
+ with (root / f"plan-{ordered[a]}.bin").open("ab") as f:
105
+ pairs.tofile(f)
106
+ output = root / "resample.parquet"
107
+ writer, pending, pending_rows, pending_bytes = None, [], 0, 0
108
+ dates, rows = set(), 0
109
+ try:
110
+ for j, leaf in enumerate(self.leaves):
111
+ plan = root / f"plan-{j}.bin"
112
+ if not plan.exists():
113
+ continue
114
+ with pq.ParquetFile(leaf.path, pre_buffer=False) as pf:
115
+ data = pl.from_arrow(pf.read())
116
+ ids = data[self.index_column].to_numpy()
117
+ starts = np.r_[0, np.flatnonzero(ids[1:] != ids[:-1]) + 1]
118
+ lengths = np.diff(np.r_[starts, len(data)])
119
+ leaf_bytes = data.estimated_size()
120
+
121
+ def blocks(pairs, leaf=leaf, lengths=lengths, leaf_bytes=leaf_bytes, data=data):
122
+ counts = np.bincount(pairs[:, 0] - leaf.start, minlength=leaf.stop - leaf.start)
123
+ nrows = int(counts @ lengths)
124
+ upper = leaf_bytes * int(counts.max()) + 16 * nrows
125
+ if upper > self.leaf_bytes * 2 and len(pairs) > 1:
126
+ mid = len(pairs) // 2
127
+ yield from blocks(pairs[:mid])
128
+ yield from blocks(pairs[mid:])
129
+ return
130
+ self._guard(4 * upper + 2 * leaf_bytes + 32e6)
131
+ selection = pl.DataFrame(
132
+ {self.index_column: pairs[:, 0], self.draw_column: pairs[:, 1]}
133
+ )
134
+ yield data.join(selection, on=self.index_column, how="inner").drop(
135
+ self.index_column
136
+ )
137
+
138
+ with plan.open("rb") as f:
139
+ while True:
140
+ pairs = np.fromfile(f, dtype="<i8", count=512).reshape(-1, 2)
141
+ if not len(pairs):
142
+ break
143
+ for frame in blocks(pairs):
144
+ dates.update(frame[time_column].unique().to_list())
145
+ rows += len(frame)
146
+ if writer is None:
147
+ writer = pq.ParquetWriter(
148
+ output,
149
+ frame.to_arrow().schema,
150
+ compression="zstd",
151
+ use_dictionary=False,
152
+ )
153
+ pending.append(frame)
154
+ pending_rows += len(frame)
155
+ pending_bytes += frame.estimated_size()
156
+ if (
157
+ pending_rows >= 32768
158
+ or pending_bytes >= self.leaf_bytes * 2
159
+ or len(pending) >= 128
160
+ ):
161
+ writer.write_table(
162
+ pl.concat(pending).to_arrow(), row_group_size=32768
163
+ )
164
+ pending, pending_rows, pending_bytes = [], 0, 0
165
+ plan.unlink()
166
+ if pending:
167
+ writer.write_table(pl.concat(pending).to_arrow(), row_group_size=32768)
168
+ finally:
169
+ if writer is not None:
170
+ writer.close()
171
+ return output, sorted(dates), rows, digest.hexdigest()
@@ -0,0 +1,90 @@
1
+ """One compiled pass over a sorted unit-period table for the staging checks.
2
+
3
+ Staging validates the panel and sums outcomes and weights by cohort and period.
4
+ In NumPy that is about fifteen passes over every row, each single-threaded.
5
+ The kernel here does the checks and sums in one parallel pass over chunks of
6
+ units; the chunks' partial sums are added in order, so the result does not
7
+ depend on the number of threads. ``PanelStore._leaf`` keeps the NumPy code for
8
+ the cases the kernel does not cover (treatment columns, spaced periods,
9
+ complete-case selection, original cohorts), and the tests compare the two.
10
+ """
11
+
12
+ import numpy as np
13
+ from numba import njit, prange
14
+
15
+ ENABLED = True # tests switch it off to compare with the NumPy path
16
+ CHUNKS = 256 # at most; fixed so results do not depend on the number of threads
17
+
18
+ DUPLICATE, COHORT, WEIGHT, BAD_WEIGHT, PERIOD = range(5)
19
+
20
+
21
+ @njit(parallel=True, cache=True)
22
+ def leaf_pass(starts, ends, t, g, y, w, has_w, scale, code, t0, per_chunk, flags, sums, mass, counts, mean_w): # fmt: skip
23
+ """Checks and sums of one sorted table; ``starts``/``ends`` delimit the units
24
+ (``code`` is each unit's cohort index), ``flags[chunk]`` marks the failed
25
+ checks and ``sums``, ``mass``, ``counts`` are per chunk by cohort and period."""
26
+ n_units = starts.shape[0]
27
+ T = sums.shape[2]
28
+ chunks = flags.shape[0]
29
+ for c in prange(chunks):
30
+ lo = c * per_chunk
31
+ hi = min(n_units, lo + per_chunk)
32
+ for u in range(lo, hi):
33
+ s = starts[u]
34
+ e = ends[u]
35
+ k = code[u]
36
+ wsum = 0.0
37
+ for r in range(s, e):
38
+ if r > s and t[r] == t[r - 1]:
39
+ flags[c, DUPLICATE] = 1
40
+ if g[r] != g[s]:
41
+ flags[c, COHORT] = 1
42
+ ti = t[r] - t0
43
+ if ti < 0 or ti >= T:
44
+ flags[c, PERIOD] = 1
45
+ continue
46
+ wr = 1.0
47
+ if has_w:
48
+ if w[r] != w[s]:
49
+ flags[c, WEIGHT] = 1
50
+ wr = w[r] / scale
51
+ if not (np.isfinite(wr) and wr > 0.0):
52
+ flags[c, BAD_WEIGHT] = 1
53
+ wsum += wr
54
+ sums[c, k, ti] += y[r] * wr
55
+ mass[c, k, ti] += wr
56
+ counts[c, k, ti] += 1
57
+ mean_w[u] = wsum / (e - s)
58
+
59
+
60
+ def summarize(starts, lengths, t, g, y, w, scale, code, n_cohorts, t0, periods):
61
+ """Flags and sums (``(cohorts, periods)`` arrays) of a sorted table; ``w`` is
62
+ None when unweighted."""
63
+ n_units = len(starts)
64
+ chunks = min(CHUNKS, max(1, n_units))
65
+ per_chunk = -(-n_units // chunks)
66
+ chunks = -(-n_units // per_chunk)
67
+ flags = np.zeros((chunks, 5), dtype=np.int8)
68
+ shape = (chunks, n_cohorts, periods)
69
+ sums, mass = np.zeros(shape), np.zeros(shape)
70
+ counts = np.zeros(shape, dtype=np.int64)
71
+ mean_w = np.zeros(n_units)
72
+ leaf_pass(
73
+ starts.astype(np.int64),
74
+ (starts + lengths).astype(np.int64),
75
+ np.ascontiguousarray(t, dtype=np.int64),
76
+ np.ascontiguousarray(g, dtype=np.int64),
77
+ np.ascontiguousarray(y, dtype=np.float64),
78
+ np.ascontiguousarray(w, dtype=np.float64) if w is not None else np.zeros(1),
79
+ w is not None,
80
+ float(scale),
81
+ np.ascontiguousarray(code, dtype=np.int64),
82
+ int(t0),
83
+ per_chunk,
84
+ flags,
85
+ sums,
86
+ mass,
87
+ counts,
88
+ mean_w,
89
+ ) # fmt: skip
90
+ return flags.max(axis=0), sums.sum(axis=0), mass.sum(axis=0), counts.sum(axis=0), mean_w