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/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)
@@ -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
+ )