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.
- diff_stream/__init__.py +52 -0
- diff_stream/_bootstrap_worker.py +48 -0
- diff_stream/_core/__init__.py +5 -0
- diff_stream/_core/contrasts.py +199 -0
- diff_stream/_core/covariate_kernels.py +420 -0
- diff_stream/_core/cs_kernels.py +536 -0
- diff_stream/_core/cs_row_kernels.py +495 -0
- diff_stream/_core/design.py +105 -0
- diff_stream/_core/etwfe_kernels.py +288 -0
- diff_stream/_core/functionals.py +30 -0
- diff_stream/_core/multiplier.py +90 -0
- diff_stream/_core/nuisance.py +255 -0
- diff_stream/_core/partition.py +158 -0
- diff_stream/_core/process.py +61 -0
- diff_stream/_core/resample.py +171 -0
- diff_stream/_core/staging_kernels.py +90 -0
- diff_stream/_core/twostage_kernels.py +342 -0
- diff_stream/_core/weights.py +26 -0
- diff_stream/_core/within.py +81 -0
- diff_stream/_docs.py +100 -0
- diff_stream/_options.py +62 -0
- diff_stream/_present.py +202 -0
- diff_stream/absorbed.py +621 -0
- diff_stream/aggregation.py +225 -0
- diff_stream/api.py +1112 -0
- diff_stream/bacon.py +460 -0
- diff_stream/bacon_controls.py +188 -0
- diff_stream/balance.py +149 -0
- diff_stream/bjs.py +101 -0
- diff_stream/bjs_placebo.py +167 -0
- diff_stream/bootstrap.py +704 -0
- diff_stream/cohort_scores.py +68 -0
- diff_stream/cs.py +197 -0
- diff_stream/cs_unbalanced.py +336 -0
- diff_stream/effective_sample.py +73 -0
- diff_stream/estimators.py +95 -0
- diff_stream/etwfe.py +589 -0
- diff_stream/gardner.py +500 -0
- diff_stream/linear.py +339 -0
- diff_stream/periods.py +72 -0
- diff_stream/pretrend.py +97 -0
- diff_stream/py.typed +0 -0
- diff_stream/reference.py +125 -0
- diff_stream/results.py +521 -0
- diff_stream/simulate.py +80 -0
- diff_stream/stacked.py +405 -0
- diff_stream/store.py +706 -0
- diff_stream/twfe_store.py +181 -0
- diff_stream/twfe_weights.py +552 -0
- diff_stream/twostage_absorbed.py +190 -0
- diff_stream/unbalanced.py +297 -0
- diff_stream-0.1.0.dev0.dist-info/METADATA +192 -0
- diff_stream-0.1.0.dev0.dist-info/RECORD +56 -0
- diff_stream-0.1.0.dev0.dist-info/WHEEL +5 -0
- diff_stream-0.1.0.dev0.dist-info/licenses/LICENSE.md +21 -0
- 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
|