shirabe-profiling 0.1.0__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 (42) hide show
  1. shirabe_profiling/__init__.py +284 -0
  2. shirabe_profiling/alerts.py +312 -0
  3. shirabe_profiling/analyzers/__init__.py +155 -0
  4. shirabe_profiling/analyzers/boolean.py +58 -0
  5. shirabe_profiling/analyzers/categorical.py +211 -0
  6. shirabe_profiling/analyzers/datetime.py +166 -0
  7. shirabe_profiling/analyzers/numeric.py +211 -0
  8. shirabe_profiling/analyzers/text.py +60 -0
  9. shirabe_profiling/config.py +112 -0
  10. shirabe_profiling/correlations.py +640 -0
  11. shirabe_profiling/dataset_overview.py +278 -0
  12. shirabe_profiling/ingest.py +79 -0
  13. shirabe_profiling/interactions.py +290 -0
  14. shirabe_profiling/py.typed +0 -0
  15. shirabe_profiling/relationships.py +292 -0
  16. shirabe_profiling/report/__init__.py +4 -0
  17. shirabe_profiling/report/charts.py +428 -0
  18. shirabe_profiling/report/html.py +858 -0
  19. shirabe_profiling/report/templates/favicon.svg +1 -0
  20. shirabe_profiling/report/templates/fonts/IBMPlexSans-LICENSE.txt +92 -0
  21. shirabe_profiling/report/templates/fonts/IBMPlexSans-Regular-Latin1.woff +0 -0
  22. shirabe_profiling/report/templates/fonts/IBMPlexSans-SemiBold-Latin1.woff +0 -0
  23. shirabe_profiling/report/templates/fonts/Lilex-OFL.txt +93 -0
  24. shirabe_profiling/report/templates/fonts/Lilex-Regular.woff +0 -0
  25. shirabe_profiling/report/templates/icons.j2 +54 -0
  26. shirabe_profiling/report/templates/report.css +1734 -0
  27. shirabe_profiling/report/templates/report.html.j2 +2018 -0
  28. shirabe_profiling/reproduction.py +79 -0
  29. shirabe_profiling/skew.py +224 -0
  30. shirabe_profiling/tables.py +161 -0
  31. shirabe_profiling/target.py +549 -0
  32. shirabe_profiling/type_inference.py +131 -0
  33. shirabe_profiling-0.1.0.dist-info/METADATA +213 -0
  34. shirabe_profiling-0.1.0.dist-info/RECORD +42 -0
  35. shirabe_profiling-0.1.0.dist-info/WHEEL +4 -0
  36. shirabe_profiling-0.1.0.dist-info/licenses/LICENSE +202 -0
  37. shirabe_profiling-0.1.0.dist-info/licenses/LICENSES/Apache-2.0.txt +202 -0
  38. shirabe_profiling-0.1.0.dist-info/licenses/LICENSES/ISC.txt +7 -0
  39. shirabe_profiling-0.1.0.dist-info/licenses/LICENSES/MIT.txt +18 -0
  40. shirabe_profiling-0.1.0.dist-info/licenses/LICENSES/OFL-1.1-RFN.txt +43 -0
  41. shirabe_profiling-0.1.0.dist-info/licenses/LICENSES/OFL-1.1-no-RFN.txt +43 -0
  42. shirabe_profiling-0.1.0.dist-info/licenses/REUSE.toml +53 -0
@@ -0,0 +1,284 @@
1
+ # SPDX-FileCopyrightText: Copyright © 2026 shuretokki
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Fast, Polars-native data profiling."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import time
10
+ from dataclasses import asdict
11
+ from typing import TYPE_CHECKING, Any, Literal
12
+
13
+ import polars as pl # noqa: TC002 — used at runtime
14
+
15
+ from shirabe_profiling.alerts import Alert, AlertType, check_quality
16
+ from shirabe_profiling.analyzers import ColumnStats, analyze
17
+ from shirabe_profiling.config import ProfileConfig
18
+ from shirabe_profiling.correlations import CorrelationResult, compute_correlations
19
+ from shirabe_profiling.dataset_overview import DatasetOverview, compute_overview
20
+ from shirabe_profiling.ingest import ingest
21
+ from shirabe_profiling.interactions import InteractionData, compute_interactions
22
+ from shirabe_profiling.report.html import render_html
23
+ from shirabe_profiling.reproduction import describe_call
24
+ from shirabe_profiling.skew import compute_skew_transforms
25
+ from shirabe_profiling.tables import ReportTables, compute_tables
26
+ from shirabe_profiling.target import TargetAnalysis, compute_target, target_alerts
27
+ from shirabe_profiling.type_inference import ColumnType, infer_types
28
+
29
+ if TYPE_CHECKING:
30
+ from pathlib import Path
31
+
32
+ __version__ = "0.1.0"
33
+
34
+ __all__ = ["ProfileReport", "ProfileConfig", "ColumnType", "AlertType", "Alert"]
35
+
36
+
37
+ def _record_duration(
38
+ reproduction: dict[str, str],
39
+ seconds: float,
40
+ ) -> None:
41
+ """Add the profiling time to the reproduction details, right after the mode."""
42
+ items = list(reproduction.items())
43
+ position = next((i for i, (key, _) in enumerate(items) if key == "mode"), len(items) - 1) + 1
44
+ items.insert(
45
+ position,
46
+ ("profiling_time", f"{seconds:.2f} s"),
47
+ )
48
+ reproduction.clear()
49
+ reproduction.update(items)
50
+
51
+
52
+ class ProfileReport:
53
+ """Main entry point for data profiling.
54
+
55
+ Usage:
56
+ report = ProfileReport(df)
57
+ report = ProfileReport(df, title="My Data")
58
+ report = ProfileReport(df, mode="overview")
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ source: Any,
64
+ *,
65
+ title: str = "Dataset Report",
66
+ mode: Literal["complete", "overview"] = "complete",
67
+ config: ProfileConfig | None = None,
68
+ **kwargs: Any,
69
+ ) -> None:
70
+ started = time.perf_counter()
71
+ self._call_code: str = describe_call(
72
+ source,
73
+ title=title,
74
+ mode=mode,
75
+ config=config,
76
+ kwargs=kwargs,
77
+ )
78
+ if config is not None:
79
+ self._config = config
80
+ else:
81
+ self._config = ProfileConfig(
82
+ title=title,
83
+ mode=mode,
84
+ **kwargs,
85
+ )
86
+
87
+ self._df: pl.DataFrame = ingest(source)
88
+ self._column_types: dict[str, ColumnType] = infer_types(
89
+ self._df,
90
+ self._config,
91
+ )
92
+ self._column_stats: dict[str, ColumnStats] = analyze(
93
+ self._df,
94
+ self._column_types,
95
+ self._config,
96
+ )
97
+ self._overview: DatasetOverview = compute_overview(
98
+ self._df,
99
+ self._column_types,
100
+ self._config,
101
+ )
102
+ self._correlations: dict[str, CorrelationResult] = compute_correlations(
103
+ self._df,
104
+ self._column_types,
105
+ self._config,
106
+ )
107
+ self._interactions: InteractionData | None = compute_interactions(
108
+ self._df,
109
+ self._column_types,
110
+ self._config,
111
+ )
112
+ self._alerts: list[Alert] = check_quality(
113
+ self._column_stats,
114
+ self._overview,
115
+ self._config,
116
+ self._correlations,
117
+ )
118
+ self._target: TargetAnalysis | None = compute_target(
119
+ self._df,
120
+ self._column_types,
121
+ self._config,
122
+ )
123
+ if self._target is not None:
124
+ self._alerts.extend(
125
+ target_alerts(
126
+ self._target,
127
+ self._config,
128
+ ),
129
+ )
130
+ self._tables: ReportTables = compute_tables(
131
+ self._df,
132
+ self._column_stats,
133
+ self._config,
134
+ )
135
+ self._skew: list[dict[str, Any]] = compute_skew_transforms(
136
+ self._df,
137
+ self._alerts,
138
+ self._config,
139
+ )
140
+ _record_duration(
141
+ self._overview.reproduction,
142
+ time.perf_counter() - started,
143
+ )
144
+
145
+ @property
146
+ def config(self) -> ProfileConfig:
147
+ return self._config
148
+
149
+ @property
150
+ def df(self) -> pl.DataFrame:
151
+ return self._df
152
+
153
+ @property
154
+ def column_types(self) -> dict[str, ColumnType]:
155
+ return self._column_types
156
+
157
+ @property
158
+ def overview(self) -> dict[str, Any]:
159
+ return asdict(self._overview)
160
+
161
+ @property
162
+ def alerts(self) -> list[Alert]:
163
+ return self._alerts
164
+
165
+ @property
166
+ def stats(self) -> dict[str, dict[str, Any]]:
167
+ return {name: asdict(s) for name, s in self._column_stats.items()}
168
+
169
+ @property
170
+ def correlations(self) -> dict[str, CorrelationResult]:
171
+ return self._correlations
172
+
173
+ @property
174
+ def interactions(self) -> InteractionData | None:
175
+ return self._interactions
176
+
177
+ @property
178
+ def target(self) -> TargetAnalysis | None:
179
+ return self._target
180
+
181
+ def to_dict(self) -> dict[str, Any]:
182
+ corr_dict: dict[str, Any] = {}
183
+ for method, cr in self._correlations.items():
184
+ entry: dict[str, Any] = {"matrix": cr.matrix.to_dicts()}
185
+ if cr.pvalues is not None:
186
+ entry["pvalues"] = cr.pvalues.to_dicts()
187
+ corr_dict[method] = entry
188
+
189
+ return {
190
+ "title": self._config.title,
191
+ "overview": self.overview,
192
+ "columns": self.stats,
193
+ "alerts": [
194
+ {
195
+ "column": a.column,
196
+ "alert_type": a.alert_type.name,
197
+ "value": a.value,
198
+ "details": a.details,
199
+ }
200
+ for a in self._alerts
201
+ ],
202
+ "correlations": corr_dict,
203
+ "interactions": self._serialize_interactions(),
204
+ "target": asdict(self._target) if self._target is not None else None,
205
+ }
206
+
207
+ def _serialize_interactions(self) -> dict[str, Any] | None:
208
+ if self._interactions is None:
209
+ return None
210
+ bp: dict[str, dict[str, list[dict]]] = {}
211
+ for cat, num_map in self._interactions.boxplot_stats.items():
212
+ bp[cat] = {}
213
+ for num, groups in num_map.items():
214
+ bp[cat][num] = [asdict(g) for g in groups]
215
+ return {
216
+ "numeric_columns": self._interactions.numeric_columns,
217
+ "categorical_columns": self._interactions.categorical_columns,
218
+ "numeric_data": self._interactions.numeric_data,
219
+ "boxplot_stats": bp,
220
+ "sampled": self._interactions.sampled,
221
+ "total_rows": self._interactions.total_rows,
222
+ "sample_size": self._interactions.sample_size,
223
+ }
224
+
225
+ def to_html(self, path: str | Path | None = None) -> str:
226
+ html = render_html(
227
+ title=self._config.title,
228
+ version=__version__,
229
+ overview=self._overview,
230
+ column_stats=self._column_stats,
231
+ alerts=self._alerts,
232
+ correlations=self._correlations,
233
+ interactions=self._interactions,
234
+ tables=self._tables,
235
+ target=self._target,
236
+ config=self._config,
237
+ reproduction_code=self._call_code,
238
+ skew=self._skew,
239
+ )
240
+ if path is not None:
241
+ from pathlib import Path as P
242
+
243
+ P(path).write_text(
244
+ html,
245
+ encoding="utf-8",
246
+ )
247
+
248
+ return html
249
+
250
+ def to_json(
251
+ self,
252
+ path: str | Path | None = None,
253
+ indent: int = 2,
254
+ ) -> str:
255
+ data = self.to_dict()
256
+ # turn the ColumnType enum into a string for JSON
257
+ for col_data in data["columns"].values():
258
+ if "column_type" in col_data:
259
+ col_data["column_type"] = col_data["column_type"].name
260
+
261
+ output = json.dumps(
262
+ data,
263
+ indent=indent,
264
+ default=str,
265
+ ensure_ascii=False,
266
+ )
267
+
268
+ if path is not None:
269
+ from pathlib import Path as P
270
+
271
+ P(path).write_text(
272
+ output,
273
+ encoding="utf-8",
274
+ )
275
+
276
+ return output
277
+
278
+ def __repr__(self) -> str:
279
+ return (
280
+ f"ProfileReport("
281
+ f"title='{self._config.title}', "
282
+ f"rows={self._df.height}, "
283
+ f"columns={self._df.width})"
284
+ )
@@ -0,0 +1,312 @@
1
+ # SPDX-FileCopyrightText: Copyright © 2026 shuretokki
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum, auto
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ import polars as pl
11
+ import polars_statistics as ps
12
+
13
+ from shirabe_profiling.analyzers import (
14
+ BooleanStats,
15
+ CategoricalStats,
16
+ NumericStats,
17
+ )
18
+ from shirabe_profiling.dataset_overview import DatasetOverview # noqa: TC001 — used at runtime
19
+
20
+ if TYPE_CHECKING:
21
+ from shirabe_profiling.analyzers import ColumnStats
22
+ from shirabe_profiling.config import ProfileConfig
23
+ from shirabe_profiling.correlations import CorrelationResult
24
+
25
+
26
+ class AlertType(Enum):
27
+ HIGH_MISSING = auto()
28
+ CONSTANT = auto()
29
+ HIGH_CARDINALITY = auto()
30
+ DUPLICATES = auto()
31
+ HIGH_ZEROS = auto()
32
+ SKEWED = auto()
33
+ IMBALANCED = auto()
34
+ HIGH_CORRELATION = auto()
35
+ UNIFORM = auto()
36
+ TARGET_LEAKAGE = auto()
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Alert:
41
+ column: str | None
42
+ alert_type: AlertType
43
+ value: float
44
+ details: dict[str, Any] = field(default_factory=dict)
45
+
46
+
47
+ def check_quality(
48
+ column_stats: dict[str, ColumnStats],
49
+ overview: DatasetOverview,
50
+ config: ProfileConfig,
51
+ correlations: dict[str, CorrelationResult] | None = None,
52
+ ) -> list[Alert]:
53
+ alerts: list[Alert] = []
54
+
55
+ if overview.duplicate_rows_pct > config.duplicate_threshold:
56
+ alerts.append(
57
+ Alert(
58
+ column=None,
59
+ alert_type=AlertType.DUPLICATES,
60
+ value=overview.duplicate_rows_pct,
61
+ ),
62
+ )
63
+
64
+ for col_name, stats in column_stats.items():
65
+ alerts.extend(
66
+ _check_column(
67
+ col_name,
68
+ stats,
69
+ config,
70
+ ),
71
+ )
72
+
73
+ if correlations:
74
+ alerts.extend(
75
+ _check_correlations(
76
+ correlations,
77
+ config,
78
+ ),
79
+ )
80
+
81
+ return alerts
82
+
83
+
84
+ def _check_column(
85
+ col_name: str,
86
+ stats: ColumnStats,
87
+ config: ProfileConfig,
88
+ ) -> list[Alert]:
89
+ alerts: list[Alert] = []
90
+
91
+ if stats.missing_pct > config.missing_threshold:
92
+ alerts.append(
93
+ Alert(
94
+ col_name,
95
+ AlertType.HIGH_MISSING,
96
+ stats.missing_pct,
97
+ ),
98
+ )
99
+
100
+ distinct = getattr(stats, "distinct_count", None)
101
+ if distinct is not None and distinct <= config.constant_threshold and stats.count > 0:
102
+ alerts.append(
103
+ Alert(
104
+ col_name,
105
+ AlertType.CONSTANT,
106
+ float(distinct),
107
+ ),
108
+ )
109
+
110
+ if isinstance(stats, NumericStats):
111
+ alerts.extend(
112
+ _check_numeric(
113
+ col_name,
114
+ stats,
115
+ config,
116
+ ),
117
+ )
118
+
119
+ if isinstance(stats, CategoricalStats):
120
+ alerts.extend(
121
+ _check_categorical(
122
+ col_name,
123
+ stats,
124
+ config,
125
+ ),
126
+ )
127
+
128
+ if isinstance(stats, BooleanStats):
129
+ alerts.extend(
130
+ _check_boolean(
131
+ col_name,
132
+ stats,
133
+ config,
134
+ ),
135
+ )
136
+
137
+ return alerts
138
+
139
+
140
+ def _check_numeric(
141
+ col_name: str,
142
+ stats: NumericStats,
143
+ config: ProfileConfig,
144
+ ) -> list[Alert]:
145
+ alerts: list[Alert] = []
146
+
147
+ if stats.distinct_pct > config.cardinality_threshold:
148
+ alerts.append(
149
+ Alert(
150
+ col_name,
151
+ AlertType.HIGH_CARDINALITY,
152
+ stats.distinct_pct,
153
+ ),
154
+ )
155
+
156
+ if stats.zeros_pct > config.zero_threshold:
157
+ alerts.append(
158
+ Alert(
159
+ col_name,
160
+ AlertType.HIGH_ZEROS,
161
+ stats.zeros_pct,
162
+ ),
163
+ )
164
+
165
+ if stats.skewness is not None and abs(stats.skewness) > config.skewness_threshold:
166
+ alerts.append(
167
+ Alert(
168
+ col_name,
169
+ AlertType.SKEWED,
170
+ abs(stats.skewness),
171
+ ),
172
+ )
173
+
174
+ return alerts
175
+
176
+
177
+ def _check_categorical(
178
+ col_name: str,
179
+ stats: CategoricalStats,
180
+ config: ProfileConfig,
181
+ ) -> list[Alert]:
182
+ alerts: list[Alert] = []
183
+
184
+ if stats.distinct_pct > config.cardinality_threshold:
185
+ alerts.append(
186
+ Alert(
187
+ col_name,
188
+ AlertType.HIGH_CARDINALITY,
189
+ stats.distinct_pct,
190
+ ),
191
+ )
192
+
193
+ if stats.top_values and stats.count > 0:
194
+ non_null = stats.count - stats.missing_count
195
+ if non_null > 0:
196
+ top_pct = stats.top_values[0]["count"] / non_null
197
+ if top_pct > config.imbalance_threshold:
198
+ alerts.append(
199
+ Alert(
200
+ col_name,
201
+ AlertType.IMBALANCED,
202
+ top_pct,
203
+ ),
204
+ )
205
+
206
+ if stats.top_values and len(stats.top_values) >= 2:
207
+ uniform_alert = _check_uniform(
208
+ col_name,
209
+ stats,
210
+ config,
211
+ )
212
+ if uniform_alert is not None:
213
+ alerts.append(uniform_alert)
214
+
215
+ return alerts
216
+
217
+
218
+ def _check_uniform(
219
+ col_name: str,
220
+ stats: CategoricalStats,
221
+ config: ProfileConfig,
222
+ ) -> Alert | None:
223
+ """Chi-squared goodness-of-fit test against uniform distribution (Rust)."""
224
+ counts = [v["count"] for v in stats.top_values if v["count"] > 0]
225
+ if len(counts) < 2:
226
+ return None
227
+
228
+ df = pl.DataFrame({"observed": counts})
229
+ try:
230
+ result = df.select(ps.chisq_goodness_of_fit("observed"))
231
+ except pl.exceptions.ComputeError:
232
+ return None
233
+
234
+ row = result.unnest(result.columns[0]).row(
235
+ 0,
236
+ named=True,
237
+ )
238
+ p_value = float(row["p_value"]) if row["p_value"] is not None else 0.0
239
+
240
+ if p_value > config.uniform_pvalue_threshold:
241
+ return Alert(
242
+ column=col_name,
243
+ alert_type=AlertType.UNIFORM,
244
+ value=p_value,
245
+ details={"p_value": p_value, "test": "chi2_gof"},
246
+ )
247
+ return None
248
+
249
+
250
+ def _check_boolean(
251
+ col_name: str,
252
+ stats: BooleanStats,
253
+ config: ProfileConfig,
254
+ ) -> list[Alert]:
255
+ alerts: list[Alert] = []
256
+
257
+ dominant_pct = max(stats.true_pct, stats.false_pct)
258
+ if dominant_pct > config.imbalance_threshold:
259
+ alerts.append(
260
+ Alert(
261
+ col_name,
262
+ AlertType.IMBALANCED,
263
+ dominant_pct,
264
+ ),
265
+ )
266
+
267
+ return alerts
268
+
269
+
270
+ def _check_correlations(
271
+ correlations: dict[str, CorrelationResult],
272
+ config: ProfileConfig,
273
+ ) -> list[Alert]:
274
+ """Flag column pairs with |correlation| above threshold.
275
+
276
+ Prefers Phi K (universal, covers all column types).
277
+ Falls back to Pearson if Phi K is absent.
278
+ """
279
+ matrix_key = "phik" if "phik" in correlations else "pearson"
280
+ if matrix_key not in correlations:
281
+ return []
282
+
283
+ matrix = correlations[matrix_key].matrix
284
+ columns = [c for c in matrix.columns if c != "column"]
285
+ seen: set[tuple[str, str]] = set()
286
+ alerts: list[Alert] = []
287
+
288
+ for row in matrix.iter_rows(named=True):
289
+ col_a = str(row["column"])
290
+ for col_b in columns:
291
+ if col_a == col_b:
292
+ continue
293
+ pair = (col_a, col_b) if col_a <= col_b else (col_b, col_a)
294
+ if pair in seen:
295
+ continue
296
+ seen.add(pair)
297
+
298
+ val = row[col_b]
299
+ if val is None:
300
+ continue
301
+ corr = abs(float(val))
302
+ if corr > config.correlation_threshold:
303
+ alerts.append(
304
+ Alert(
305
+ column=col_a,
306
+ alert_type=AlertType.HIGH_CORRELATION,
307
+ value=corr,
308
+ details={"column_b": col_b, "method": matrix_key},
309
+ ),
310
+ )
311
+
312
+ return alerts