dataframe-toolkit 0.4.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.
@@ -0,0 +1,360 @@
1
+ """
2
+ dftk.commands.describe_cmd — dftk describe subcommand.
3
+
4
+ Profiles each column in a DataFrame: data type, basic statistics, missing
5
+ values, and automatically generated quality/distribution notes.
6
+
7
+ Output columns (one row per input column)
8
+ ------------------------------------------
9
+ name, type, count, missing_pct, n_unique,
10
+ mean, std, min, p25, median, p75, max, skew, kurtosis,
11
+ top, top_freq_pct, notes
12
+
13
+ Numeric columns populate mean…kurtosis; top/top_freq_pct are blank.
14
+ Categorical/boolean columns populate top/top_freq_pct; numeric stats are blank.
15
+
16
+ Notes flags
17
+ -----------
18
+ all_missing — column has no non-null values
19
+ high_missing — > 20 % missing
20
+ constant — only one distinct value
21
+ near_constant — dominant value covers > 95 % of non-null rows
22
+ possible_id — every non-null value is unique (n_unique == n_rows)
23
+ high_cardinality — categorical with > 50 distinct values
24
+ outliers_iqr — at least one value outside 1.5 × IQR fences
25
+ approx_normal — |skew| < 0.5 and |excess_kurtosis| < 1
26
+ right_skewed — skew > 1
27
+ left_skewed — skew < -1
28
+ heavy_tailed — excess kurtosis > 3
29
+ bimodal_hint — bimodality coefficient > 0.555 (Pfister et al.)
30
+
31
+ Optional flags
32
+ --------------
33
+ --summary Print a human-readable summary paragraph to stderr.
34
+ --correlations Include pairwise correlation analysis in the summary.
35
+ --corr-threshold Minimum |r| to report (default: 0.7).
36
+
37
+ Example
38
+ -------
39
+ dftk describe data.tsv
40
+ dftk describe data.tsv --summary --correlations
41
+ """
42
+
43
+ import argparse
44
+ import sys
45
+
46
+ import numpy as np
47
+ import pandas as pd
48
+
49
+ from dftk.commands.base import BaseCommand
50
+ from dftk.common.io import io
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Column type detection
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def _col_type(col: pd.Series) -> str:
58
+ if pd.api.types.is_bool_dtype(col):
59
+ return "boolean"
60
+ if pd.api.types.is_numeric_dtype(col):
61
+ return "numeric"
62
+ if pd.api.types.is_datetime64_any_dtype(col):
63
+ return "datetime"
64
+ return "categorical"
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Single-column profiling
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ def _profile_col(col: pd.Series, n_rows: int) -> dict:
73
+ """Return a dict describing one column."""
74
+ col_type = _col_type(col)
75
+ n_missing = int(col.isna().sum())
76
+ missing_pct = round(100.0 * n_missing / n_rows, 1) if n_rows > 0 else 0.0
77
+ n_valid = n_rows - n_missing
78
+ n_unique = int(col.nunique())
79
+
80
+ row: dict = dict(
81
+ name=col.name,
82
+ type=col_type,
83
+ count=n_valid,
84
+ missing_pct=missing_pct,
85
+ n_unique=n_unique,
86
+ mean=np.nan,
87
+ std=np.nan,
88
+ min=np.nan,
89
+ p25=np.nan,
90
+ median=np.nan,
91
+ p75=np.nan,
92
+ max=np.nan,
93
+ skew=np.nan,
94
+ kurtosis=np.nan,
95
+ top="",
96
+ top_freq_pct=np.nan,
97
+ notes="",
98
+ )
99
+
100
+ notes: list[str] = []
101
+
102
+ if missing_pct == 100.0:
103
+ notes.append("all_missing")
104
+ row["notes"] = ",".join(notes)
105
+ return row
106
+ if missing_pct > 20.0:
107
+ notes.append("high_missing")
108
+
109
+ if n_unique == 0:
110
+ row["notes"] = ",".join(notes)
111
+ return row
112
+
113
+ if n_unique == 1:
114
+ notes.append("constant")
115
+
116
+ if n_unique == n_rows and n_missing == 0:
117
+ notes.append("possible_id")
118
+
119
+ if col_type == "numeric":
120
+ vals = col.dropna()
121
+
122
+ row["mean"] = float(vals.mean())
123
+ row["std"] = float(vals.std())
124
+ row["min"] = float(vals.min())
125
+ row["p25"] = float(vals.quantile(0.25))
126
+ row["median"] = float(vals.median())
127
+ row["p75"] = float(vals.quantile(0.75))
128
+ row["max"] = float(vals.max())
129
+
130
+ if len(vals) >= 4:
131
+ skew = float(vals.skew())
132
+ kurt = float(vals.kurtosis()) # excess kurtosis (normal = 0)
133
+ row["skew"] = round(skew, 4)
134
+ row["kurtosis"] = round(kurt, 4)
135
+
136
+ # IQR outlier detection
137
+ q1, q3 = row["p25"], row["p75"]
138
+ iqr = q3 - q1
139
+ if (
140
+ iqr > 0
141
+ and int(((vals < q1 - 1.5 * iqr) | (vals > q3 + 1.5 * iqr)).sum()) > 0
142
+ ):
143
+ notes.append("outliers_iqr")
144
+
145
+ # Distribution shape
146
+ if abs(skew) < 0.5 and abs(kurt) < 1.0:
147
+ notes.append("approx_normal")
148
+ else:
149
+ if skew > 1.0:
150
+ notes.append("right_skewed")
151
+ elif skew < -1.0:
152
+ notes.append("left_skewed")
153
+ if kurt > 3.0:
154
+ notes.append("heavy_tailed")
155
+
156
+ # Bimodality coefficient (Pfister et al. 2013)
157
+ n = len(vals)
158
+ if n > 5:
159
+ denom = kurt + 3.0 * (n - 1) ** 2 / ((n - 2) * (n - 3))
160
+ if abs(denom) > 1e-9 and (skew**2 + 1.0) / denom > 0.555:
161
+ notes.append("bimodal_hint")
162
+
163
+ else:
164
+ vc = col.value_counts()
165
+ if len(vc) > 0:
166
+ row["top"] = str(vc.index[0])
167
+ row["top_freq_pct"] = (
168
+ round(100.0 * int(vc.iloc[0]) / n_valid, 1) if n_valid > 0 else 0.0
169
+ )
170
+
171
+ if n_unique > 1 and row["top_freq_pct"] > 95.0:
172
+ notes.append("near_constant")
173
+
174
+ if col_type == "categorical" and n_unique > 50:
175
+ notes.append("high_cardinality")
176
+
177
+ row["notes"] = ",".join(notes)
178
+ return row
179
+
180
+
181
+ # ---------------------------------------------------------------------------
182
+ # Correlation analysis
183
+ # ---------------------------------------------------------------------------
184
+
185
+
186
+ def _correlations(df: pd.DataFrame, threshold: float = 0.7) -> list[tuple]:
187
+ """Return (col_a, col_b, r) pairs where |r| >= threshold, sorted by |r|."""
188
+ num_cols = df.select_dtypes(include="number").columns.tolist()
189
+ if len(num_cols) < 2:
190
+ return []
191
+ corr = df[num_cols].corr()
192
+ pairs = []
193
+ for i, a in enumerate(num_cols):
194
+ for b in num_cols[i + 1 :]:
195
+ r = corr.loc[a, b]
196
+ if not np.isnan(r) and abs(r) >= threshold:
197
+ pairs.append((a, b, round(float(r), 4)))
198
+ pairs.sort(key=lambda x: -abs(x[2]))
199
+ return pairs
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Human-readable summary
204
+ # ---------------------------------------------------------------------------
205
+
206
+
207
+ def _flag_names(profile: pd.DataFrame, flag: str) -> list[str]:
208
+ return [str(r["name"]) for _, r in profile.iterrows() if flag in str(r["notes"])]
209
+
210
+
211
+ def _generate_summary(
212
+ profile: pd.DataFrame,
213
+ corr_pairs: list[tuple],
214
+ n_rows: int,
215
+ n_cols: int,
216
+ ) -> str:
217
+ lines = [f"Dataset: {n_rows} rows × {n_cols} columns"]
218
+
219
+ missing = profile[profile["missing_pct"] > 0].sort_values(
220
+ "missing_pct", ascending=False
221
+ )
222
+ if len(missing):
223
+ parts = [f"'{r['name']}' ({r['missing_pct']}%)" for _, r in missing.iterrows()]
224
+ lines.append("Missing data: " + ", ".join(parts))
225
+
226
+ for flag, label in [
227
+ ("constant", "Constant columns"),
228
+ ("near_constant", "Near-constant columns"),
229
+ ("possible_id", "Possible identifiers (all unique)"),
230
+ ("high_cardinality", "High cardinality"),
231
+ ]:
232
+ cols = _flag_names(profile, flag)
233
+ if cols:
234
+ lines.append(f"{label}: " + ", ".join(f"'{c}'" for c in cols))
235
+
236
+ dist_parts = []
237
+ for _, r in profile[profile["type"] == "numeric"].iterrows():
238
+ n = str(r["notes"])
239
+ tags = []
240
+ if "approx_normal" in n:
241
+ tags.append("approx. normal")
242
+ if "right_skewed" in n:
243
+ tags.append("right-skewed")
244
+ if "left_skewed" in n:
245
+ tags.append("left-skewed")
246
+ if "heavy_tailed" in n:
247
+ tags.append("heavy-tailed")
248
+ if "bimodal_hint" in n:
249
+ tags.append("possibly bimodal")
250
+ if "outliers_iqr" in n:
251
+ tags.append("has outliers (IQR)")
252
+ if tags:
253
+ dist_parts.append(f"'{r['name']}': {', '.join(tags)}")
254
+ if dist_parts:
255
+ lines.append("Distributions: " + "; ".join(dist_parts))
256
+
257
+ if corr_pairs:
258
+ parts = [f"'{a}' ↔ '{b}' (r={r})" for a, b, r in corr_pairs]
259
+ lines.append("High correlations: " + ", ".join(parts))
260
+
261
+ return "\n".join(lines)
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # Command
266
+ # ---------------------------------------------------------------------------
267
+
268
+
269
+ class DescribeCommand(BaseCommand):
270
+ @property
271
+ def name(self) -> str:
272
+ return "describe"
273
+
274
+ @property
275
+ def help(self) -> str:
276
+ return "Profile DataFrame columns: types, distributions, and quality flags"
277
+
278
+ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
279
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
280
+ parser.epilog = """\
281
+ OUTPUT COLUMNS
282
+ --------------
283
+ One row per input column:
284
+
285
+ name column name
286
+ type numeric | categorical | boolean | datetime
287
+ count number of non-null values
288
+ missing_pct percentage of null values
289
+ n_unique number of distinct values
290
+ mean std (numeric only)
291
+ min p25 median p75 max
292
+ skew kurtosis excess kurtosis (normal distribution = 0)
293
+ top most frequent value (categorical/boolean)
294
+ top_freq_pct frequency of top value as a percentage
295
+ notes comma-separated quality/distribution flags (see below)
296
+
297
+
298
+ NOTES FLAGS
299
+ -----------
300
+ all_missing no non-null values
301
+ high_missing > 20 % of values are null
302
+ constant only one distinct value
303
+ near_constant dominant value covers > 95 % of non-null rows
304
+ possible_id every non-null value is unique (likely an identifier)
305
+ high_cardinality categorical column with > 50 distinct values
306
+ outliers_iqr at least one value outside the 1.5 × IQR fences
307
+ approx_normal |skew| < 0.5 and |excess kurtosis| < 1
308
+ right_skewed skew > 1
309
+ left_skewed skew < -1
310
+ heavy_tailed excess kurtosis > 3
311
+ bimodal_hint bimodality coefficient > 0.555 (Pfister et al. 2013)
312
+
313
+
314
+ EXAMPLES
315
+ --------
316
+ dftk describe data.tsv
317
+ dftk describe data.tsv --summary --correlations
318
+ dftk describe data.tsv --summary --correlations --corr-threshold 0.5
319
+ dftk dataset iris | dftk describe - --summary --correlations
320
+ """
321
+ io.parser_read(parser)
322
+
323
+ g = parser.add_argument_group("describe options")
324
+ g.add_argument(
325
+ "--summary",
326
+ help="Print a human-readable summary paragraph to stderr.",
327
+ action="store_true",
328
+ )
329
+ g.add_argument(
330
+ "--correlations",
331
+ help="Compute pairwise correlations and include them in --summary output.",
332
+ action="store_true",
333
+ )
334
+ g.add_argument(
335
+ "--corr-threshold",
336
+ help="Minimum |r| to report as a high correlation (default: 0.7).",
337
+ type=float,
338
+ default=0.7,
339
+ metavar="FLOAT",
340
+ )
341
+
342
+ io.parser_output(parser)
343
+
344
+ def execute(self, args: argparse.Namespace) -> None:
345
+ df = io.read(args)
346
+ n_rows, n_cols = df.shape
347
+
348
+ profile = pd.DataFrame([_profile_col(df[c], n_rows) for c in df.columns])
349
+
350
+ corr_pairs: list[tuple] = []
351
+ if getattr(args, "correlations", False):
352
+ threshold = getattr(args, "corr_threshold", 0.7)
353
+ corr_pairs = _correlations(df, threshold)
354
+
355
+ if getattr(args, "summary", False):
356
+ print(
357
+ _generate_summary(profile, corr_pairs, n_rows, n_cols), file=sys.stderr
358
+ )
359
+
360
+ io.printdf(profile, args)