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.
- shirabe_profiling/__init__.py +284 -0
- shirabe_profiling/alerts.py +312 -0
- shirabe_profiling/analyzers/__init__.py +155 -0
- shirabe_profiling/analyzers/boolean.py +58 -0
- shirabe_profiling/analyzers/categorical.py +211 -0
- shirabe_profiling/analyzers/datetime.py +166 -0
- shirabe_profiling/analyzers/numeric.py +211 -0
- shirabe_profiling/analyzers/text.py +60 -0
- shirabe_profiling/config.py +112 -0
- shirabe_profiling/correlations.py +640 -0
- shirabe_profiling/dataset_overview.py +278 -0
- shirabe_profiling/ingest.py +79 -0
- shirabe_profiling/interactions.py +290 -0
- shirabe_profiling/py.typed +0 -0
- shirabe_profiling/relationships.py +292 -0
- shirabe_profiling/report/__init__.py +4 -0
- shirabe_profiling/report/charts.py +428 -0
- shirabe_profiling/report/html.py +858 -0
- shirabe_profiling/report/templates/favicon.svg +1 -0
- shirabe_profiling/report/templates/fonts/IBMPlexSans-LICENSE.txt +92 -0
- shirabe_profiling/report/templates/fonts/IBMPlexSans-Regular-Latin1.woff +0 -0
- shirabe_profiling/report/templates/fonts/IBMPlexSans-SemiBold-Latin1.woff +0 -0
- shirabe_profiling/report/templates/fonts/Lilex-OFL.txt +93 -0
- shirabe_profiling/report/templates/fonts/Lilex-Regular.woff +0 -0
- shirabe_profiling/report/templates/icons.j2 +54 -0
- shirabe_profiling/report/templates/report.css +1734 -0
- shirabe_profiling/report/templates/report.html.j2 +2018 -0
- shirabe_profiling/reproduction.py +79 -0
- shirabe_profiling/skew.py +224 -0
- shirabe_profiling/tables.py +161 -0
- shirabe_profiling/target.py +549 -0
- shirabe_profiling/type_inference.py +131 -0
- shirabe_profiling-0.1.0.dist-info/METADATA +213 -0
- shirabe_profiling-0.1.0.dist-info/RECORD +42 -0
- shirabe_profiling-0.1.0.dist-info/WHEEL +4 -0
- shirabe_profiling-0.1.0.dist-info/licenses/LICENSE +202 -0
- shirabe_profiling-0.1.0.dist-info/licenses/LICENSES/Apache-2.0.txt +202 -0
- shirabe_profiling-0.1.0.dist-info/licenses/LICENSES/ISC.txt +7 -0
- shirabe_profiling-0.1.0.dist-info/licenses/LICENSES/MIT.txt +18 -0
- shirabe_profiling-0.1.0.dist-info/licenses/LICENSES/OFL-1.1-RFN.txt +43 -0
- shirabe_profiling-0.1.0.dist-info/licenses/LICENSES/OFL-1.1-no-RFN.txt +43 -0
- 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
|