393 lines
16 KiB
Python
393 lines
16 KiB
Python
"""
|
||
数据预处理校验阶段。在 load_csv_directory 之后、build_entity_profiles 之前自动运行。
|
||
不阻塞流程,只记录 warning 到日志。
|
||
"""
|
||
import logging
|
||
import json
|
||
from typing import Any, Optional
|
||
|
||
import polars as pl
|
||
import numpy as np
|
||
|
||
from analysis.session_store import SessionStore
|
||
from analysis.type_classifier import classify_column, DataType
|
||
from analysis.ip_clustering import ip_to_int
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Mapping from Polars dtype strings to DataType for schema comparison
|
||
_DTYPE_TO_DATATYPE: dict[str, DataType] = {
|
||
'Int8': DataType.INT, 'Int16': DataType.INT, 'Int32': DataType.INT, 'Int64': DataType.INT,
|
||
'UInt8': DataType.INT, 'UInt16': DataType.INT, 'UInt32': DataType.INT, 'UInt64': DataType.INT,
|
||
'Float32': DataType.FLOAT, 'Float64': DataType.FLOAT,
|
||
'Boolean': DataType.BOOL_ENUM, 'Bool': DataType.BOOL_ENUM,
|
||
'String': DataType.STRING, 'Utf8': DataType.STRING, 'Categorical': DataType.STRING,
|
||
'Date': DataType.TIMESTAMP, 'Datetime': DataType.TIMESTAMP,
|
||
'Time': DataType.TIMESTAMP, 'Duration': DataType.TIMESTAMP,
|
||
}
|
||
|
||
_MAX_OUTLIER_SAMPLE = 1000
|
||
"""Max rows to sample for Z-score outlier detection."""
|
||
|
||
_MAX_IPV4_INVALID_SAMPLES = 10
|
||
"""Max invalid IPv4 samples to include in the report."""
|
||
|
||
|
||
def _dtype_to_datatype(dtype_str: str) -> Optional[DataType]:
|
||
"""Map a Polars dtype string (e.g. ``'String'``, ``'Int64'``) to :class:`DataType`.
|
||
|
||
Strips any type parameters (e.g. ``Datetime(time_unit='us', time_zone=None)``)
|
||
before lookup.
|
||
"""
|
||
base = dtype_str.split('(')[0].strip()
|
||
return _DTYPE_TO_DATATYPE.get(base)
|
||
|
||
|
||
def _safe_float(val: Any) -> Optional[float]:
|
||
"""Convert *val* to float or return *None* on failure."""
|
||
if val is None:
|
||
return None
|
||
try:
|
||
f = float(val)
|
||
return None if np.isnan(f) or np.isinf(f) else round(f, 6)
|
||
except (ValueError, TypeError):
|
||
return None
|
||
|
||
|
||
def _is_acceptable_mismatch(schema_type: DataType, inferred_type: DataType) -> bool:
|
||
"""Return *True* if a schema vs. inferred type mismatch is benign.
|
||
|
||
Acceptable mismatches:
|
||
- Schema says ``STRING`` but classifier infers a more specific type
|
||
(``IPv4``, ``URL``, ``HEX``, etc.) — this is expected because
|
||
``data_loader`` only sees raw Polars dtypes while ``classify_column``
|
||
applies value-based heuristics.
|
||
- ``INT`` ↔ ``FLOAT`` — Polars can cast between numeric types freely.
|
||
- ``ENUM`` ↔ ``BOOL_ENUM`` — both are low-cardinality categorical types.
|
||
"""
|
||
if schema_type == DataType.STRING:
|
||
return True
|
||
if {schema_type, inferred_type} == {DataType.INT, DataType.FLOAT}:
|
||
return True
|
||
if {schema_type, inferred_type} == {DataType.ENUM, DataType.BOOL_ENUM}:
|
||
return True
|
||
return False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Public API
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def validate(dataset_id: str, strict: bool = False) -> dict:
|
||
"""运行完整校验,返回 JSON 报告。
|
||
|
||
校验项
|
||
------
|
||
1. **列类型** — 用 :func:`~analysis.type_classifier.classify_column`
|
||
判定每列类型,与 *schema* 对比矛盾。
|
||
2. **缺失率** — 每列 ``null_count / len``,>50% 标记为高风险。
|
||
3. **异常值** — 数值列的 ``min`` / ``max`` / ``std``,Z-score > 5 比例。
|
||
4. **枚举分布** — ``ENUM`` / ``BOOL_ENUM`` 的值频率,特殊符号
|
||
(``+``、空白)标记。
|
||
5. **IPv4 有效性** — 对 ``IPv4`` 类型列的每个值调用
|
||
:func:`~analysis.ip_clustering.ip_to_int` 验证。
|
||
|
||
输出
|
||
----
|
||
每条校验结果通过 :data:`logger` 以 ``[VALIDATE]`` 标记写入
|
||
``info`` 或 ``warning`` 级别。
|
||
|
||
参数
|
||
----------
|
||
dataset_id:
|
||
SessionStore 中的数据集 ID。
|
||
strict:
|
||
为 *True* 时遇到高风险项(缺失率 >50%、无效 IPv4 比例 >50%)
|
||
抛出 :class:`RuntimeError`。默认为 *False*(仅记录 warning)。
|
||
|
||
返回
|
||
-------
|
||
dict
|
||
包含 ``valid``、``columns``、``warnings``、``risks`` 等键的
|
||
校验报告。
|
||
"""
|
||
store = SessionStore()
|
||
entry = store.get_dataset(dataset_id)
|
||
if entry is None:
|
||
msg = f"Dataset '{dataset_id}' not found in SessionStore"
|
||
logger.error(f'[VALIDATE] {msg}')
|
||
return {'valid': False, 'columns': {}, 'warnings': [], 'risks': [msg]}
|
||
|
||
lf: pl.LazyFrame = entry['lazyframe']
|
||
schema: dict = entry.get('schema', {})
|
||
|
||
# Materialise the LazyFrame
|
||
try:
|
||
df = lf.collect(streaming=True)
|
||
except Exception as e:
|
||
msg = f"Failed to collect LazyFrame for dataset '{dataset_id}': {e}"
|
||
logger.error(f'[VALIDATE] {msg}')
|
||
return {'valid': False, 'columns': {}, 'warnings': [], 'risks': [msg]}
|
||
|
||
total_rows = len(df)
|
||
logger.info(f'[VALIDATE] dataset={dataset_id} rows={total_rows} columns={len(df.columns)}')
|
||
|
||
columns_report: dict[str, dict] = {}
|
||
all_warnings: list[str] = []
|
||
all_risks: list[str] = []
|
||
|
||
for col_name in df.columns:
|
||
series = df[col_name]
|
||
col_info: dict[str, Any] = {
|
||
'name': col_name,
|
||
'dtype': str(series.dtype),
|
||
}
|
||
col_warnings: list[str] = []
|
||
|
||
# ── 1. Column type classification ────────────────────────────────
|
||
inferred_type = classify_column(col_name, series, config_type=None)
|
||
col_info['inferred_type'] = inferred_type.name
|
||
|
||
schema_dtype_str = schema.get(col_name)
|
||
if schema_dtype_str:
|
||
schema_type = _dtype_to_datatype(schema_dtype_str)
|
||
col_info['schema_type'] = schema_type.name if schema_type else schema_dtype_str
|
||
if schema_type and schema_type != inferred_type:
|
||
if not _is_acceptable_mismatch(schema_type, inferred_type):
|
||
msg = (
|
||
f"Column '{col_name}': schema type ({schema_type.name}) "
|
||
f"differs from inferred type ({inferred_type.name})"
|
||
)
|
||
col_warnings.append(msg)
|
||
all_warnings.append(msg)
|
||
else:
|
||
col_info['schema_type'] = None
|
||
|
||
# ── 2. Missing rate ──────────────────────────────────────────────
|
||
null_count = int(series.null_count())
|
||
null_rate = null_count / total_rows if total_rows > 0 else 0.0
|
||
col_info['null_count'] = null_count
|
||
col_info['null_rate'] = round(null_rate, 4)
|
||
|
||
if null_rate > 0.5:
|
||
msg = (
|
||
f"Column '{col_name}': null rate {null_rate:.1%} "
|
||
f"exceeds 50% (high risk)"
|
||
)
|
||
col_warnings.append(msg)
|
||
all_risks.append(msg)
|
||
elif null_rate > 0.3:
|
||
msg = (
|
||
f"Column '{col_name}': null rate {null_rate:.1%} "
|
||
f"exceeds 30%"
|
||
)
|
||
col_warnings.append(msg)
|
||
all_warnings.append(msg)
|
||
|
||
# ── 3. Outlier detection (numeric columns) ───────────────────────
|
||
if inferred_type in (DataType.INT, DataType.FLOAT):
|
||
non_null = series.drop_nulls()
|
||
if len(non_null) > 1:
|
||
try:
|
||
col_info['min'] = _safe_float(non_null.min())
|
||
col_info['max'] = _safe_float(non_null.max())
|
||
col_info['std'] = _safe_float(non_null.std())
|
||
|
||
# Sample for Z-score computation
|
||
if len(non_null) > _MAX_OUTLIER_SAMPLE:
|
||
sample = non_null.sample(n=_MAX_OUTLIER_SAMPLE, seed=42)
|
||
else:
|
||
sample = non_null
|
||
|
||
num_values = sample.cast(pl.Float64).to_numpy()
|
||
mean = float(np.nanmean(num_values))
|
||
std = float(np.nanstd(num_values))
|
||
if std > 0:
|
||
z_scores = np.abs((num_values - mean) / std)
|
||
outlier_ratio = float(np.mean(z_scores > 5))
|
||
col_info['z_score_gt_5_ratio'] = round(outlier_ratio, 4)
|
||
if outlier_ratio > 0.05:
|
||
msg = (
|
||
f"Column '{col_name}': {outlier_ratio:.1%} "
|
||
f"values have |Z-score| > 5 (high outlier ratio)"
|
||
)
|
||
col_warnings.append(msg)
|
||
all_warnings.append(msg)
|
||
else:
|
||
col_info['z_score_gt_5_ratio'] = 0.0
|
||
except Exception as e:
|
||
col_info['numeric_error'] = str(e)
|
||
col_info['z_score_gt_5_ratio'] = 0.0
|
||
else:
|
||
col_info['z_score_gt_5_ratio'] = 0.0
|
||
|
||
# ── 4. Enum distribution ─────────────────────────────────────────
|
||
if inferred_type in (DataType.ENUM, DataType.BOOL_ENUM):
|
||
non_null = series.drop_nulls()
|
||
if len(non_null) > 0:
|
||
vc = non_null.value_counts()
|
||
val_col = vc.columns[0]
|
||
cnt_col = vc.columns[1]
|
||
total_non_null = len(non_null)
|
||
distribution: dict[str, dict] = {}
|
||
for row in vc.iter_rows(named=True):
|
||
val = str(row[val_col])
|
||
count = int(row[cnt_col])
|
||
distribution[val] = {
|
||
'count': count,
|
||
'ratio': round(count / total_non_null, 4),
|
||
}
|
||
col_info['distribution'] = distribution
|
||
|
||
# Flag special symbols (empty string, whitespace-only, +)
|
||
for val, info in distribution.items():
|
||
stripped = val.strip()
|
||
if stripped == '' or stripped == '+':
|
||
msg = (
|
||
f"Column '{col_name}': "
|
||
f"value {repr(val)} appears {info['count']} times"
|
||
)
|
||
col_warnings.append(msg)
|
||
all_warnings.append(msg)
|
||
break # one warning per column
|
||
|
||
# ── 5. IPv4 validation ───────────────────────────────────────────
|
||
if inferred_type == DataType.IPv4:
|
||
non_null = series.drop_nulls()
|
||
total_valid = 0
|
||
total_invalid = 0
|
||
invalid_samples: list[str] = []
|
||
for v in non_null:
|
||
ip_str = str(v).strip()
|
||
if ip_to_int(ip_str) is not None:
|
||
total_valid += 1
|
||
else:
|
||
total_invalid += 1
|
||
if len(invalid_samples) < _MAX_IPV4_INVALID_SAMPLES:
|
||
invalid_samples.append(ip_str)
|
||
|
||
col_info['ipv4_valid_count'] = total_valid
|
||
col_info['ipv4_invalid_count'] = total_invalid
|
||
col_info['ipv4_invalid_samples'] = invalid_samples
|
||
|
||
if total_invalid > 0:
|
||
total_ipv4 = total_valid + total_invalid
|
||
invalid_ratio = total_invalid / total_ipv4 if total_ipv4 > 0 else 1.0
|
||
sample_str = str(invalid_samples[:5])
|
||
msg = (
|
||
f"Column '{col_name}': {total_invalid}/{total_ipv4} "
|
||
f"({invalid_ratio:.1%}) values are invalid IPv4. "
|
||
f"Samples: {sample_str}"
|
||
)
|
||
col_warnings.append(msg)
|
||
if invalid_ratio > 0.5:
|
||
all_risks.append(msg)
|
||
else:
|
||
all_warnings.append(msg)
|
||
|
||
col_info['warnings'] = col_warnings
|
||
columns_report[col_name] = col_info
|
||
|
||
# ── Log all findings ─────────────────────────────────────────────────
|
||
for col_name, col_info in columns_report.items():
|
||
for w in col_info.get('warnings', []):
|
||
logger.warning(f'[VALIDATE] {w}')
|
||
|
||
report_data: dict[str, Any] = {
|
||
'valid': len(all_risks) == 0,
|
||
'dataset_id': dataset_id,
|
||
'total_rows': total_rows,
|
||
'total_columns': len(df.columns),
|
||
'columns': columns_report,
|
||
'warnings': all_warnings,
|
||
'risks': all_risks,
|
||
}
|
||
|
||
n_warn = len(all_warnings)
|
||
n_risk = len(all_risks)
|
||
logger.info(
|
||
f'[VALIDATE] Complete: valid={report_data["valid"]} '
|
||
f'warnings={n_warn} risks={n_risk}'
|
||
)
|
||
|
||
if strict and all_risks:
|
||
raise RuntimeError(
|
||
f'Validation strict mode: {len(all_risks)} risk(s) found:\n' +
|
||
'\n'.join(f' - {r}' for r in all_risks)
|
||
)
|
||
|
||
return report_data
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Formatted report
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def report(dataset_id: str) -> str:
|
||
"""返回格式化的校验报告字符串。
|
||
|
||
参数
|
||
----------
|
||
dataset_id:
|
||
SessionStore 中的数据集 ID。
|
||
|
||
返回
|
||
-------
|
||
str
|
||
人类可读的格式化报告。
|
||
"""
|
||
result = validate(dataset_id, strict=False)
|
||
|
||
lines: list[str] = []
|
||
lines.append('=' * 60)
|
||
lines.append(f'Data Validation Report — dataset: {dataset_id}')
|
||
lines.append('=' * 60)
|
||
lines.append(f' Valid: {result["valid"]}')
|
||
lines.append(f' Total rows: {result.get("total_rows", "?")}')
|
||
lines.append(f' Total columns: {result.get("total_columns", "?")}')
|
||
lines.append(f' Warnings: {len(result.get("warnings", []))}')
|
||
lines.append(f' Risks: {len(result.get("risks", []))}')
|
||
lines.append('')
|
||
|
||
columns = result.get('columns', {})
|
||
for col_name, col_info in sorted(columns.items()):
|
||
lines.append(f' --- {col_name} ---')
|
||
lines.append(f' dtype: {col_info.get("dtype", "?")}')
|
||
lines.append(f' inferred: {col_info.get("inferred_type", "?")}')
|
||
lines.append(f' null_rate: {col_info.get("null_rate", "?")}')
|
||
if 'min' in col_info:
|
||
cmin = col_info.get('min')
|
||
cmax = col_info.get('max')
|
||
lines.append(f' range: [{cmin}, {cmax}]')
|
||
if 'z_score_gt_5_ratio' in col_info:
|
||
lines.append(f' |Z|>5 ratio: {col_info["z_score_gt_5_ratio"]}')
|
||
if 'distribution' in col_info:
|
||
dist = col_info['distribution']
|
||
top5 = sorted(dist.items(), key=lambda kv: kv[1]['count'], reverse=True)[:5]
|
||
top5_str = ', '.join(f'{k}={v["count"]}' for k, v in top5)
|
||
lines.append(f' top values: {top5_str}')
|
||
if 'ipv4_invalid_count' in col_info and col_info['ipv4_invalid_count'] > 0:
|
||
lines.append(
|
||
f' invalid IPv4: {col_info["ipv4_invalid_count"]} '
|
||
f'(samples: {col_info.get("ipv4_invalid_samples", [])})'
|
||
)
|
||
for w in col_info.get('warnings', []):
|
||
lines.append(f' ! {w}')
|
||
lines.append('')
|
||
|
||
if result.get('warnings'):
|
||
lines.append('Warnings:')
|
||
for w in result['warnings']:
|
||
lines.append(f' - {w}')
|
||
lines.append('')
|
||
if result.get('risks'):
|
||
lines.append('Risks:')
|
||
for r in result['risks']:
|
||
lines.append(f' # {r}')
|
||
lines.append('')
|
||
|
||
lines.append('=' * 60)
|
||
return '\n'.join(lines)
|