data-sampler 3.2.1__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.
- data_sampler/__init__.py +82 -0
- data_sampler/__main__.py +7 -0
- data_sampler/_logging.py +61 -0
- data_sampler/_names.py +60 -0
- data_sampler/anonymize.py +463 -0
- data_sampler/cli.py +363 -0
- data_sampler/engine.py +628 -0
- data_sampler/io.py +75 -0
- data_sampler/report.py +289 -0
- data_sampler/sampling.py +193 -0
- data_sampler/stats.py +174 -0
- data_sampler/tui/__init__.py +16 -0
- data_sampler/tui/app.py +904 -0
- data_sampler/workflow.py +285 -0
- data_sampler-3.2.1.dist-info/METADATA +427 -0
- data_sampler-3.2.1.dist-info/RECORD +19 -0
- data_sampler-3.2.1.dist-info/WHEEL +4 -0
- data_sampler-3.2.1.dist-info/entry_points.txt +3 -0
- data_sampler-3.2.1.dist-info/licenses/LICENSE +21 -0
data_sampler/io.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""File loading and saving for all supported tabular formats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
SUPPORTED_EXTENSIONS = (".csv", ".tsv", ".json", ".xlsx", ".xls", ".parquet")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_file(filepath: str | Path, sheet: str | None = None) -> pd.DataFrame:
|
|
13
|
+
"""Load a data file into a DataFrame.
|
|
14
|
+
|
|
15
|
+
Supports CSV, TSV, JSON, Excel (.xlsx/.xls) and Parquet. For Excel files,
|
|
16
|
+
``sheet`` selects the sheet by name (default: first sheet).
|
|
17
|
+
"""
|
|
18
|
+
ext = Path(filepath).suffix.lower()
|
|
19
|
+
readers = {
|
|
20
|
+
".csv": lambda: pd.read_csv(filepath),
|
|
21
|
+
".tsv": lambda: pd.read_csv(filepath, sep="\t"),
|
|
22
|
+
".json": lambda: pd.read_json(filepath),
|
|
23
|
+
".xlsx": lambda: pd.read_excel(filepath, sheet_name=sheet or 0),
|
|
24
|
+
".xls": lambda: pd.read_excel(filepath, sheet_name=sheet or 0),
|
|
25
|
+
".parquet": lambda: pd.read_parquet(filepath),
|
|
26
|
+
}
|
|
27
|
+
if ext not in readers:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"Unsupported file type: '{ext}'. "
|
|
30
|
+
f"Supported: {', '.join(readers.keys())}"
|
|
31
|
+
)
|
|
32
|
+
return readers[ext]()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def list_sheets(filepath: str | Path) -> list[str]:
|
|
36
|
+
"""Return the sheet names of an Excel file (empty list for other formats)."""
|
|
37
|
+
ext = Path(filepath).suffix.lower()
|
|
38
|
+
if ext not in (".xlsx", ".xls"):
|
|
39
|
+
return []
|
|
40
|
+
with pd.ExcelFile(filepath) as xls:
|
|
41
|
+
return [str(s) for s in xls.sheet_names]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def save_output(
|
|
45
|
+
df: pd.DataFrame,
|
|
46
|
+
source_path: str | Path,
|
|
47
|
+
tag: str,
|
|
48
|
+
output_folder: str | Path | None = None,
|
|
49
|
+
) -> Path:
|
|
50
|
+
"""Save ``df`` next to ``source_path`` (or into ``output_folder``).
|
|
51
|
+
|
|
52
|
+
The output keeps the source file's format and is named
|
|
53
|
+
``{stem}_{tag}{ext}`` (e.g. ``data_sample_500.csv``).
|
|
54
|
+
"""
|
|
55
|
+
p = Path(source_path)
|
|
56
|
+
out_name = f"{p.stem}_{tag}{p.suffix}"
|
|
57
|
+
out_dir = Path(output_folder) if output_folder else p.parent
|
|
58
|
+
out_path = out_dir / out_name
|
|
59
|
+
ext = p.suffix.lower()
|
|
60
|
+
|
|
61
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
if ext == ".csv":
|
|
63
|
+
df.to_csv(out_path, index=False)
|
|
64
|
+
elif ext == ".tsv":
|
|
65
|
+
df.to_csv(out_path, sep="\t", index=False)
|
|
66
|
+
elif ext in (".xlsx", ".xls"):
|
|
67
|
+
df.to_excel(out_path, index=False)
|
|
68
|
+
elif ext == ".json":
|
|
69
|
+
df.to_json(out_path, orient="records", indent=2)
|
|
70
|
+
elif ext == ".parquet":
|
|
71
|
+
df.to_parquet(out_path, index=False)
|
|
72
|
+
else:
|
|
73
|
+
raise ValueError(f"Unsupported output file type: '{ext}'")
|
|
74
|
+
|
|
75
|
+
return out_path
|
data_sampler/report.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Terminal-friendly stratification reports (returned as strings, not printed)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from .sampling import SampleResult
|
|
11
|
+
from .stats import _classify, _fmt_num
|
|
12
|
+
|
|
13
|
+
BAR_WIDTH = 30 # max width of distribution bars
|
|
14
|
+
HIST_BAR_WIDTH = 20 # max width of the per-column comparison histogram bars
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def format_distribution(df: pd.DataFrame, col: str, label: str = "Source") -> str:
|
|
18
|
+
"""Build a bar chart of a column's distribution."""
|
|
19
|
+
out: list[str] = []
|
|
20
|
+
counts = df[col].value_counts().sort_index()
|
|
21
|
+
total = counts.sum()
|
|
22
|
+
max_count = counts.max()
|
|
23
|
+
max_label_len = max(len(str(v)) for v in counts.index)
|
|
24
|
+
|
|
25
|
+
out.append(f"\n {label} distribution for '{col}' ({counts.shape[0]} categories):")
|
|
26
|
+
out.append(f" {'─' * (max_label_len + BAR_WIDTH + 20)}")
|
|
27
|
+
|
|
28
|
+
for value, count in counts.items():
|
|
29
|
+
pct = count / total * 100
|
|
30
|
+
bar_len = int(count / max_count * BAR_WIDTH) if max_count > 0 else 0
|
|
31
|
+
bar = "█" * bar_len + "░" * (BAR_WIDTH - bar_len)
|
|
32
|
+
out.append(f" {str(value):>{max_label_len}} {bar} {count:>6} ({pct:5.1f}%)")
|
|
33
|
+
|
|
34
|
+
out.append(f" {'─' * (max_label_len + BAR_WIDTH + 20)}")
|
|
35
|
+
return "\n".join(out)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def column_histogram_data(
|
|
39
|
+
df_source: pd.DataFrame,
|
|
40
|
+
df_sample: pd.DataFrame,
|
|
41
|
+
bins: int = 10,
|
|
42
|
+
top: int = 8,
|
|
43
|
+
) -> list[dict]:
|
|
44
|
+
"""Per-column source-vs-sample distributions, aligned for comparison.
|
|
45
|
+
|
|
46
|
+
Returns one dict per column present in both frames::
|
|
47
|
+
|
|
48
|
+
{"name", "kind", "labels": [str],
|
|
49
|
+
"source_counts": [int], "sample_counts": [int],
|
|
50
|
+
"source_pct": [float], "sample_pct": [float]}
|
|
51
|
+
|
|
52
|
+
Numeric columns share bin edges (computed from the source) so the two
|
|
53
|
+
histograms line up bin-for-bin; other columns use the source's ``top``
|
|
54
|
+
most-frequent categories, looked up in the sample. Percentages are of each
|
|
55
|
+
frame's non-null count, so for categorical columns the shown bars may sum
|
|
56
|
+
to under 100 % when there are more than ``top`` categories.
|
|
57
|
+
|
|
58
|
+
Pass the *pre-anonymization* sample (``SampleResult.data``) so the numbers
|
|
59
|
+
describe how well the sample preserved the source distribution.
|
|
60
|
+
"""
|
|
61
|
+
out: list[dict] = []
|
|
62
|
+
for col in df_source.columns:
|
|
63
|
+
if col not in df_sample.columns:
|
|
64
|
+
continue
|
|
65
|
+
src = df_source[col]
|
|
66
|
+
samp = df_sample[col]
|
|
67
|
+
kind = _classify(src)
|
|
68
|
+
|
|
69
|
+
if kind == "numeric":
|
|
70
|
+
s_src = pd.to_numeric(src, errors="coerce")
|
|
71
|
+
s_src = s_src[np.isfinite(s_src)]
|
|
72
|
+
s_samp = pd.to_numeric(samp, errors="coerce")
|
|
73
|
+
s_samp = s_samp[np.isfinite(s_samp)]
|
|
74
|
+
if len(s_src) == 0:
|
|
75
|
+
continue
|
|
76
|
+
edges = np.histogram_bin_edges(s_src, bins=bins)
|
|
77
|
+
source_counts = np.histogram(s_src, bins=edges)[0]
|
|
78
|
+
sample_counts = (
|
|
79
|
+
np.histogram(s_samp, bins=edges)[0]
|
|
80
|
+
if len(s_samp)
|
|
81
|
+
else np.zeros(len(source_counts), dtype=int)
|
|
82
|
+
)
|
|
83
|
+
labels = [
|
|
84
|
+
f"{_fmt_num(float(edges[i]))} – {_fmt_num(float(edges[i + 1]))}"
|
|
85
|
+
for i in range(len(source_counts))
|
|
86
|
+
]
|
|
87
|
+
# percentages over the FINITE counts the bins were built from —
|
|
88
|
+
# ±inf values are excluded from the bars, so including them in the
|
|
89
|
+
# denominator would silently deflate every percentage
|
|
90
|
+
src_total = len(s_src) or 1
|
|
91
|
+
samp_total = len(s_samp) or 1
|
|
92
|
+
else:
|
|
93
|
+
src_nonnull = src.dropna()
|
|
94
|
+
if src_nonnull.empty:
|
|
95
|
+
continue
|
|
96
|
+
# a near-unique column (ids, emails, free text) has no meaningful
|
|
97
|
+
# top-8 distribution — skip it rather than hash-count every value
|
|
98
|
+
# to draw eight bars of count 1
|
|
99
|
+
n_unique = src_nonnull.nunique()
|
|
100
|
+
if n_unique > max(100, 0.5 * len(src_nonnull)):
|
|
101
|
+
continue
|
|
102
|
+
src_vc = src_nonnull.astype(str).value_counts()
|
|
103
|
+
top_labels = list(src_vc.head(top).index)
|
|
104
|
+
samp_vc = samp.dropna().astype(str).value_counts()
|
|
105
|
+
source_counts = np.array([int(src_vc.get(l, 0)) for l in top_labels])
|
|
106
|
+
sample_counts = np.array([int(samp_vc.get(l, 0)) for l in top_labels])
|
|
107
|
+
labels = [str(l) for l in top_labels]
|
|
108
|
+
src_total = int(src.notna().sum()) or 1
|
|
109
|
+
samp_total = int(samp.notna().sum()) or 1
|
|
110
|
+
out.append(
|
|
111
|
+
{
|
|
112
|
+
"name": str(col),
|
|
113
|
+
"kind": kind,
|
|
114
|
+
"labels": labels,
|
|
115
|
+
"source_counts": [int(c) for c in source_counts],
|
|
116
|
+
"sample_counts": [int(c) for c in sample_counts],
|
|
117
|
+
"source_pct": [c / src_total * 100 for c in source_counts],
|
|
118
|
+
"sample_pct": [c / samp_total * 100 for c in sample_counts],
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def format_column_histograms(
|
|
125
|
+
df_source: pd.DataFrame,
|
|
126
|
+
df_sample: pd.DataFrame,
|
|
127
|
+
bins: int = 10,
|
|
128
|
+
top: int = 8,
|
|
129
|
+
) -> str:
|
|
130
|
+
"""Text render of :func:`column_histogram_data`: per-column source-vs-sample
|
|
131
|
+
bars, so you can eyeball how each column's distribution held up."""
|
|
132
|
+
data = column_histogram_data(df_source, df_sample, bins=bins, top=top)
|
|
133
|
+
if not data:
|
|
134
|
+
return ""
|
|
135
|
+
|
|
136
|
+
out: list[str] = []
|
|
137
|
+
out.append("╔═════════════════════════════════════════════════════════════════════╗")
|
|
138
|
+
out.append("║ COLUMN DISTRIBUTIONS (source → sample) ║")
|
|
139
|
+
out.append("╚═════════════════════════════════════════════════════════════════════╝")
|
|
140
|
+
|
|
141
|
+
for d in data:
|
|
142
|
+
labels = d["labels"]
|
|
143
|
+
if not labels:
|
|
144
|
+
continue
|
|
145
|
+
label_w = min(20, max(len(l) for l in labels))
|
|
146
|
+
peak = max([*d["source_pct"], *d["sample_pct"], 1e-9])
|
|
147
|
+
out.append(f"\n {d['name']} ({d['kind']})")
|
|
148
|
+
for label, s_pct, m_pct in zip(labels, d["source_pct"], d["sample_pct"]):
|
|
149
|
+
lbl = label if len(label) <= label_w else label[: label_w - 1] + "…"
|
|
150
|
+
s_len = int(s_pct / peak * HIST_BAR_WIDTH)
|
|
151
|
+
m_len = int(m_pct / peak * HIST_BAR_WIDTH)
|
|
152
|
+
s_bar = "█" * s_len + "░" * (HIST_BAR_WIDTH - s_len)
|
|
153
|
+
m_bar = "█" * m_len + "░" * (HIST_BAR_WIDTH - m_len)
|
|
154
|
+
out.append(
|
|
155
|
+
f" {lbl:>{label_w}} src {s_bar} {s_pct:5.1f}% "
|
|
156
|
+
f"sam {m_bar} {m_pct:5.1f}%"
|
|
157
|
+
)
|
|
158
|
+
return "\n".join(out)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def format_stratification_report(
|
|
162
|
+
df_original: pd.DataFrame, result: SampleResult
|
|
163
|
+
) -> str:
|
|
164
|
+
"""Build the side-by-side distribution comparison for a stratified sample.
|
|
165
|
+
|
|
166
|
+
For random / all-rows results, returns the run notes only.
|
|
167
|
+
"""
|
|
168
|
+
if result.method != "stratified":
|
|
169
|
+
return "\n".join(result.notes)
|
|
170
|
+
|
|
171
|
+
df_sample = result.data
|
|
172
|
+
strat_cols = result.strat_cols
|
|
173
|
+
group_sizes = result.group_sizes
|
|
174
|
+
allocations = result.allocations
|
|
175
|
+
sample_count = result.requested
|
|
176
|
+
|
|
177
|
+
out: list[str] = []
|
|
178
|
+
out.append("╔═════════════════════════════════════════════════════════════════════╗")
|
|
179
|
+
out.append("║ STRATIFICATION REPORT ║")
|
|
180
|
+
out.append("╚═════════════════════════════════════════════════════════════════════╝")
|
|
181
|
+
out.append(f"\n Columns used: {', '.join(strat_cols)}")
|
|
182
|
+
|
|
183
|
+
if len(strat_cols) > 1:
|
|
184
|
+
out.append(
|
|
185
|
+
"\n Method: rows are grouped by the intersection of all stratification\n"
|
|
186
|
+
" columns simultaneously (e.g. A=x AND B=y AND C=z). Proportional\n"
|
|
187
|
+
" allocation is then computed per intersection group. A category value\n"
|
|
188
|
+
" that is small in every intersection group it appears in will receive\n"
|
|
189
|
+
" 0 allocation — even if it looks sizable in the per-column view below."
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
out.append(
|
|
193
|
+
f"\n Method: rows are grouped by '{strat_cols[0]}' and sampled proportionally."
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
for col in strat_cols:
|
|
197
|
+
orig_counts = df_original[col].value_counts(dropna=False).sort_index(na_position="last")
|
|
198
|
+
samp_counts = df_sample[col].value_counts(dropna=False).sort_index(na_position="last")
|
|
199
|
+
orig_total = orig_counts.sum()
|
|
200
|
+
samp_total = samp_counts.sum()
|
|
201
|
+
|
|
202
|
+
# sort with NaN last; use pandas isna to safely detect NaN keys
|
|
203
|
+
raw_values = orig_counts.index.tolist()
|
|
204
|
+
all_values = sorted((v for v in raw_values if not pd.isna(v)), key=str) + \
|
|
205
|
+
[v for v in raw_values if pd.isna(v)]
|
|
206
|
+
|
|
207
|
+
def make_label(v):
|
|
208
|
+
s = "(missing)" if pd.isna(v) else str(v)
|
|
209
|
+
return s if len(s) <= 13 else s[:10] + "..."
|
|
210
|
+
|
|
211
|
+
all_labels = [make_label(v) for v in all_values]
|
|
212
|
+
max_label_len = 13
|
|
213
|
+
half_bar = BAR_WIDTH // 2
|
|
214
|
+
|
|
215
|
+
def get_count(counts, v):
|
|
216
|
+
if pd.isna(v):
|
|
217
|
+
nan_mask = counts.index.isna()
|
|
218
|
+
return int(counts[nan_mask].sum()) if nan_mask.any() else 0
|
|
219
|
+
return int(counts.get(v, 0))
|
|
220
|
+
|
|
221
|
+
zero_rep = [v for v in all_values if get_count(samp_counts, v) == 0]
|
|
222
|
+
|
|
223
|
+
out.append(f"\n Column: '{col}' ({len(all_values)} categories)")
|
|
224
|
+
header = f" {'Value':>{max_label_len}} {'Original':^{half_bar + 10}} {'Sample':^{half_bar + 10}}"
|
|
225
|
+
out.append(header)
|
|
226
|
+
out.append(f" {'─' * len(header)}")
|
|
227
|
+
|
|
228
|
+
orig_max = orig_counts.max() if len(orig_counts) > 0 else 1
|
|
229
|
+
samp_max = samp_counts.max() if len(samp_counts) > 0 else 1
|
|
230
|
+
|
|
231
|
+
for value, label in zip(all_values, all_labels):
|
|
232
|
+
o_count = get_count(orig_counts, value)
|
|
233
|
+
s_count = get_count(samp_counts, value)
|
|
234
|
+
o_pct = o_count / orig_total * 100 if orig_total > 0 else 0
|
|
235
|
+
s_pct = s_count / samp_total * 100 if samp_total > 0 else 0
|
|
236
|
+
|
|
237
|
+
o_bar_len = int(o_count / orig_max * half_bar) if orig_max > 0 else 0
|
|
238
|
+
s_bar_len = int(s_count / samp_max * half_bar) if samp_max > 0 else 0
|
|
239
|
+
|
|
240
|
+
o_bar = "█" * o_bar_len + "░" * (half_bar - o_bar_len)
|
|
241
|
+
s_bar = "█" * s_bar_len + "░" * (half_bar - s_bar_len)
|
|
242
|
+
|
|
243
|
+
flag = " ← not represented" if s_count == 0 else ""
|
|
244
|
+
out.append(
|
|
245
|
+
f" {label:>{max_label_len}} "
|
|
246
|
+
f"{o_bar} {o_pct:5.1f}% "
|
|
247
|
+
f"{s_bar} {s_pct:5.1f}%{flag}"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
out.append(f" {'─' * len(header)}")
|
|
251
|
+
out.append(
|
|
252
|
+
f" {'Totals':>{max_label_len}} {' ' * half_bar} {orig_total:>5} "
|
|
253
|
+
f"{' ' * half_bar} {samp_total:>5}"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if zero_rep and group_sizes is not None and allocations is not None:
|
|
257
|
+
total_rows = int(group_sizes.sum())
|
|
258
|
+
# min group size needed to receive at least 1 sample
|
|
259
|
+
min_size_for_one = math.ceil(total_rows / sample_count)
|
|
260
|
+
|
|
261
|
+
out.append(
|
|
262
|
+
f"\n Warning: {len(zero_rep)} of {len(all_values)} categories in '{col}' "
|
|
263
|
+
f"received 0 samples.\n"
|
|
264
|
+
f" (A group needs ≥ {min_size_for_one} rows to receive ≥ 1 sample at this scale.)"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if len(strat_cols) > 1:
|
|
268
|
+
# For each zero-represented value, look up its intersection sub-groups
|
|
269
|
+
col_idx = strat_cols.index(col)
|
|
270
|
+
for value in zero_rep:
|
|
271
|
+
if isinstance(group_sizes.index, pd.MultiIndex):
|
|
272
|
+
mask = group_sizes.index.get_level_values(col_idx) == value
|
|
273
|
+
sub_sizes = group_sizes[mask]
|
|
274
|
+
sub_allocs = allocations[mask]
|
|
275
|
+
else:
|
|
276
|
+
sub_sizes = group_sizes[[value]] if value in group_sizes.index else pd.Series(dtype=int)
|
|
277
|
+
sub_allocs = allocations[[value]] if value in allocations.index else pd.Series(dtype=int)
|
|
278
|
+
|
|
279
|
+
n_subgroups = len(sub_sizes)
|
|
280
|
+
largest = int(sub_sizes.max()) if n_subgroups > 0 else 0
|
|
281
|
+
total_alloc = int(sub_allocs.sum())
|
|
282
|
+
|
|
283
|
+
out.append(
|
|
284
|
+
f" {str(value):>{max_label_len}}: {n_subgroups} intersection group(s), "
|
|
285
|
+
f"largest has {largest} rows (needs {min_size_for_one}), "
|
|
286
|
+
f"total allocated = {total_alloc}"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return "\n".join(out)
|
data_sampler/sampling.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Stratified and random sampling engine.
|
|
2
|
+
|
|
3
|
+
Pure logic — nothing here prints. Callers (CLI, TUI) render the
|
|
4
|
+
:class:`SampleResult` via :mod:`data_sampler.report`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Iterable
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
from ._logging import get_logger
|
|
17
|
+
from .stats import is_stratifiable
|
|
18
|
+
|
|
19
|
+
log = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SampleResult:
|
|
24
|
+
"""Outcome of a sampling run, with everything needed to build a report."""
|
|
25
|
+
|
|
26
|
+
data: pd.DataFrame
|
|
27
|
+
method: str # "stratified" | "random" | "all"
|
|
28
|
+
requested: int
|
|
29
|
+
strat_cols: list[str] = field(default_factory=list)
|
|
30
|
+
group_sizes: pd.Series | None = None
|
|
31
|
+
allocations: pd.Series | None = None
|
|
32
|
+
notes: list[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def find_stratification_columns(
|
|
36
|
+
df: pd.DataFrame,
|
|
37
|
+
sample_count: int,
|
|
38
|
+
exclude: Iterable[str] = (),
|
|
39
|
+
) -> list[str]:
|
|
40
|
+
"""Pick columns suitable for stratification.
|
|
41
|
+
|
|
42
|
+
Candidates are categorical / low-cardinality columns with 2–100 unique
|
|
43
|
+
values; long text and ID-like numeric columns are skipped. Columns listed
|
|
44
|
+
in ``exclude`` are never considered (the user's "skip" selections). The
|
|
45
|
+
final set is pruned so the intersection-group count fits ``sample_count``.
|
|
46
|
+
"""
|
|
47
|
+
exclude = set(exclude)
|
|
48
|
+
candidates = []
|
|
49
|
+
n_rows = len(df)
|
|
50
|
+
|
|
51
|
+
for col in df.columns:
|
|
52
|
+
if col in exclude:
|
|
53
|
+
log.debug("stratification: column %r excluded by user", col)
|
|
54
|
+
continue
|
|
55
|
+
series = df[col]
|
|
56
|
+
n_unique = series.nunique()
|
|
57
|
+
if not is_stratifiable(series, n_rows, n_unique=n_unique):
|
|
58
|
+
continue
|
|
59
|
+
candidates.append((col, n_unique))
|
|
60
|
+
|
|
61
|
+
# sort by fewest categories first (easiest to represent)
|
|
62
|
+
candidates.sort(key=lambda x: x[1])
|
|
63
|
+
|
|
64
|
+
# prune: only keep columns whose combined group count fits the sample size
|
|
65
|
+
selected = []
|
|
66
|
+
combo_count = 1
|
|
67
|
+
for col, n_unique in candidates:
|
|
68
|
+
new_combo = combo_count * n_unique
|
|
69
|
+
if new_combo > sample_count:
|
|
70
|
+
break
|
|
71
|
+
combo_count = new_combo
|
|
72
|
+
selected.append(col)
|
|
73
|
+
|
|
74
|
+
log.debug("stratification columns selected: %s", selected)
|
|
75
|
+
return selected
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def stratified_sample(
|
|
79
|
+
df: pd.DataFrame,
|
|
80
|
+
count: int,
|
|
81
|
+
strat_cols: list[str],
|
|
82
|
+
random_state: int | np.random.Generator | None = None,
|
|
83
|
+
) -> tuple[pd.DataFrame, pd.Series, pd.Series]:
|
|
84
|
+
"""Sample ``count`` rows preserving the joint distribution of ``strat_cols``.
|
|
85
|
+
|
|
86
|
+
Returns ``(sampled_df, group_sizes, allocations)``.
|
|
87
|
+
"""
|
|
88
|
+
rng = np.random.default_rng(random_state) if not isinstance(
|
|
89
|
+
random_state, np.random.Generator
|
|
90
|
+
) else random_state
|
|
91
|
+
|
|
92
|
+
grouped = df.groupby(strat_cols, observed=True, dropna=False)
|
|
93
|
+
group_sizes = grouped.size()
|
|
94
|
+
total = group_sizes.sum()
|
|
95
|
+
|
|
96
|
+
# compute proportional allocation
|
|
97
|
+
allocations = (group_sizes / total * count).apply(math.floor)
|
|
98
|
+
|
|
99
|
+
# distribute remaining slots to the largest remainders
|
|
100
|
+
remainders = (group_sizes / total * count) - allocations
|
|
101
|
+
shortfall = count - int(allocations.sum())
|
|
102
|
+
if shortfall > 0:
|
|
103
|
+
top_positions = remainders.values.argsort()[-shortfall:]
|
|
104
|
+
for pos in top_positions:
|
|
105
|
+
allocations.iloc[pos] += 1
|
|
106
|
+
|
|
107
|
+
# sample from each group
|
|
108
|
+
samples = []
|
|
109
|
+
for i, (_, group_df) in enumerate(grouped):
|
|
110
|
+
n = int(allocations.iloc[i])
|
|
111
|
+
if n == 0:
|
|
112
|
+
continue
|
|
113
|
+
n = min(n, len(group_df))
|
|
114
|
+
samples.append(group_df.sample(n=n, random_state=rng))
|
|
115
|
+
|
|
116
|
+
result = pd.concat(samples).sample(frac=1, random_state=rng) # shuffle
|
|
117
|
+
|
|
118
|
+
# adjust if rounding left us short/over
|
|
119
|
+
if len(result) < count:
|
|
120
|
+
remaining = df.drop(result.index)
|
|
121
|
+
extra = remaining.sample(
|
|
122
|
+
n=min(count - len(result), len(remaining)), random_state=rng
|
|
123
|
+
)
|
|
124
|
+
result = pd.concat([result, extra])
|
|
125
|
+
elif len(result) > count:
|
|
126
|
+
result = result.head(count)
|
|
127
|
+
|
|
128
|
+
return result, group_sizes, allocations
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def sample(
|
|
132
|
+
df: pd.DataFrame,
|
|
133
|
+
count: int,
|
|
134
|
+
use_random: bool = False,
|
|
135
|
+
exclude_columns: Iterable[str] = (),
|
|
136
|
+
random_state: int | np.random.Generator | None = None,
|
|
137
|
+
) -> SampleResult:
|
|
138
|
+
"""Create a representative sample of ``count`` rows.
|
|
139
|
+
|
|
140
|
+
Stratifies automatically on suitable columns unless ``use_random`` is
|
|
141
|
+
set. Columns in ``exclude_columns`` are never used for stratification
|
|
142
|
+
(they still appear in the output). Returns a :class:`SampleResult`.
|
|
143
|
+
"""
|
|
144
|
+
log.info("sampling %d of %d rows (random=%s)", count, len(df), use_random)
|
|
145
|
+
|
|
146
|
+
if count >= len(df):
|
|
147
|
+
return SampleResult(
|
|
148
|
+
data=df,
|
|
149
|
+
method="all",
|
|
150
|
+
requested=count,
|
|
151
|
+
notes=[
|
|
152
|
+
f"Requested {count} samples but file only has {len(df)} rows. "
|
|
153
|
+
"Returning all rows."
|
|
154
|
+
],
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
rng = np.random.default_rng(random_state) if not isinstance(
|
|
158
|
+
random_state, np.random.Generator
|
|
159
|
+
) else random_state
|
|
160
|
+
|
|
161
|
+
if use_random:
|
|
162
|
+
return SampleResult(
|
|
163
|
+
data=df.sample(n=count, random_state=rng),
|
|
164
|
+
method="random",
|
|
165
|
+
requested=count,
|
|
166
|
+
notes=["Mode: pure random sampling"],
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
strat_cols = find_stratification_columns(df, count, exclude=exclude_columns)
|
|
170
|
+
|
|
171
|
+
if not strat_cols:
|
|
172
|
+
return SampleResult(
|
|
173
|
+
data=df.sample(n=count, random_state=rng),
|
|
174
|
+
method="random",
|
|
175
|
+
requested=count,
|
|
176
|
+
notes=[
|
|
177
|
+
"No suitable columns for stratification found. "
|
|
178
|
+
"Using pure random sampling."
|
|
179
|
+
],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
result, group_sizes, allocations = stratified_sample(
|
|
183
|
+
df, count, strat_cols, random_state=rng
|
|
184
|
+
)
|
|
185
|
+
return SampleResult(
|
|
186
|
+
data=result,
|
|
187
|
+
method="stratified",
|
|
188
|
+
requested=count,
|
|
189
|
+
strat_cols=strat_cols,
|
|
190
|
+
group_sizes=group_sizes,
|
|
191
|
+
allocations=allocations,
|
|
192
|
+
notes=[f"Stratifying on columns: {strat_cols}"],
|
|
193
|
+
)
|