Files
stefan-jansen--machine-lear…/utils/data_quality.py
T
2026-07-13 13:26:28 +08:00

568 lines
17 KiB
Python

"""Data quality and filtering utilities for data loading.
This module provides centralized functions for:
- Coverage summaries (rows, symbols, date range)
- OHLC invariant checks
- Null rate analysis
- Gap detection in time series
- Symbol subsetting for test-mode execution
Usage:
>>> from utils.data_quality import describe_coverage, check_ohlc_invariants
>>> coverage = describe_coverage(df, time_col="timestamp", asset_col="symbol")
>>> invariants = check_ohlc_invariants(df)
"""
from __future__ import annotations
import random
from datetime import timedelta
from typing import TYPE_CHECKING
import polars as pl
if TYPE_CHECKING:
from collections.abc import Sequence
def apply_max_symbols(
data: pl.DataFrame | pl.LazyFrame,
max_symbols: int,
symbol_col: str = "symbol",
seed: int = 42,
) -> pl.DataFrame | pl.LazyFrame:
"""Limit data to a random subset of symbols for fast-path testing.
Selects a reproducible random sample of symbols using a fixed seed.
Returns data unchanged if max_symbols <= 0 or >= total symbols.
"""
if max_symbols <= 0:
return data
if isinstance(data, pl.LazyFrame):
all_symbols = data.select(pl.col(symbol_col).unique()).collect()[symbol_col].to_list()
else:
all_symbols = data[symbol_col].unique().to_list()
if max_symbols >= len(all_symbols):
return data
rng = random.Random(seed)
selected = rng.sample(sorted(all_symbols), max_symbols)
return data.filter(pl.col(symbol_col).is_in(selected))
def describe_coverage(
df: pl.DataFrame,
time_col: str = "timestamp",
asset_col: str = "symbol",
) -> dict:
"""Return coverage summary for a dataset.
Args:
df: DataFrame with time and asset columns
time_col: Name of the timestamp/date column
asset_col: Name of the asset identifier column
Returns:
Dictionary with rows, assets, time_min, time_max, unique_times
"""
return {
"rows": df.height,
"assets": df[asset_col].n_unique() if asset_col in df.columns else 0,
"time_min": df[time_col].min(),
"time_max": df[time_col].max(),
"unique_times": df[time_col].n_unique(),
}
def print_coverage(
df: pl.DataFrame,
time_col: str = "timestamp",
asset_col: str = "symbol",
dataset_name: str = "Dataset",
) -> None:
"""Print formatted coverage summary."""
cov = describe_coverage(df, time_col, asset_col)
print(f"=== {dataset_name} Coverage ===")
print(f" Rows: {cov['rows']:,}")
print(f" Assets: {cov['assets']:,}")
print(f" Time range: {cov['time_min']} to {cov['time_max']}")
print(f" Unique times: {cov['unique_times']:,}")
def check_ohlc_invariants(
df: pl.DataFrame,
open_col: str = "open",
high_col: str = "high",
low_col: str = "low",
close_col: str = "close",
volume_col: str = "volume",
) -> pl.DataFrame:
"""Check OHLC data quality invariants.
Validates:
- high >= low
- high >= open
- high >= close
- low <= open
- low <= close
- volume >= 0 (if volume column exists)
For each check, only rows where all relevant columns are non-null are
considered. This prevents null comparisons from distorting percentages
(important for TAQ data where trade columns may be null for no-trade bars).
Args:
df: DataFrame with OHLC columns
open_col, high_col, low_col, close_col: Column names for OHLC
volume_col: Column name for volume (optional)
Returns:
DataFrame with check names and valid_pct columns
"""
results = []
total_rows = df.height
cols = set(df.columns)
def _check_invariant(name: str, condition: pl.Expr, required_cols: list[str]) -> None:
"""Check an invariant on rows where all required columns are non-null."""
# Filter to rows where all required columns are non-null
not_null_filter = pl.all_horizontal([pl.col(c).is_not_null() for c in required_cols])
applicable = df.filter(not_null_filter)
n_applicable = applicable.height
if n_applicable == 0:
return # Skip if no applicable rows
valid_pct = applicable.select(condition.mean()).item() * 100
results.append(
{
"check": name,
"valid_pct": valid_pct,
"applicable_rows": n_applicable,
"total_rows": total_rows,
}
)
# Define checks with their required columns
if {high_col, low_col}.issubset(cols):
_check_invariant(
"high_gte_low",
pl.col(high_col) >= pl.col(low_col),
[high_col, low_col],
)
if {high_col, open_col}.issubset(cols):
_check_invariant(
"high_gte_open",
pl.col(high_col) >= pl.col(open_col),
[high_col, open_col],
)
if {high_col, close_col}.issubset(cols):
_check_invariant(
"high_gte_close",
pl.col(high_col) >= pl.col(close_col),
[high_col, close_col],
)
if {low_col, open_col}.issubset(cols):
_check_invariant(
"low_lte_open",
pl.col(low_col) <= pl.col(open_col),
[low_col, open_col],
)
if {low_col, close_col}.issubset(cols):
_check_invariant(
"low_lte_close",
pl.col(low_col) <= pl.col(close_col),
[low_col, close_col],
)
if volume_col in cols:
_check_invariant(
"volume_non_negative",
pl.col(volume_col) >= 0,
[volume_col],
)
if not results:
return pl.DataFrame({"check": [], "valid_pct": [], "applicable_rows": [], "total_rows": []})
return pl.DataFrame(results)
def print_ohlc_invariants(
df: pl.DataFrame,
open_col: str = "open",
high_col: str = "high",
low_col: str = "low",
close_col: str = "close",
volume_col: str = "volume",
show_coverage: bool = False,
) -> None:
"""Print OHLC invariant check results.
Args:
show_coverage: If True, show how many rows each check applies to
"""
result = check_ohlc_invariants(df, open_col, high_col, low_col, close_col, volume_col)
print("=== OHLC Invariants ===")
for row in result.iter_rows(named=True):
status = "[OK]" if row["valid_pct"] >= 99.99 else "[WARN]"
coverage = ""
if show_coverage and row["applicable_rows"] < row["total_rows"]:
coverage = f" ({row['applicable_rows']:,}/{row['total_rows']:,} rows)"
print(f" {status} {row['check']}: {row['valid_pct']:.2f}%{coverage}")
def null_rate(
df: pl.DataFrame,
cols: Sequence[str] | None = None,
) -> pl.DataFrame:
"""Calculate null rates for specified columns.
Args:
df: DataFrame to analyze
cols: Columns to check (default: all columns)
Returns:
DataFrame with column names and null_pct
"""
if cols is None:
cols = df.columns
else:
cols = [c for c in cols if c in df.columns]
if not cols:
return pl.DataFrame({"column": [], "null_pct": []})
rates = df.select([pl.col(c).is_null().mean().alias(c) for c in cols])
return pl.DataFrame(
{
"column": list(rates.columns),
"null_pct": [rates[col].item() * 100 for col in rates.columns],
}
)
def print_null_rates(
df: pl.DataFrame,
cols: Sequence[str] | None = None,
threshold: float = 0.0,
) -> None:
"""Print null rates for columns exceeding threshold.
Args:
df: DataFrame to analyze
cols: Columns to check (default: all columns)
threshold: Only print columns with null_pct > threshold
"""
result = null_rate(df, cols)
result = result.filter(pl.col("null_pct") > threshold)
print("=== Null Rates ===")
if result.height == 0:
print(" No nulls detected")
else:
for row in result.iter_rows(named=True):
print(f" {row['column']}: {row['null_pct']:.2f}%")
def gap_summary(
df: pl.DataFrame,
time_col: str = "timestamp",
group_col: str | None = "symbol",
expected_delta: timedelta | None = None,
) -> pl.DataFrame:
"""Identify gaps in time series data.
Args:
df: DataFrame with time series data
time_col: Name of timestamp column
group_col: Column to group by (e.g., symbol). None for ungrouped.
expected_delta: Expected time between rows (e.g., timedelta(hours=1))
Returns:
DataFrame with gap statistics per group (if grouped) or overall
"""
df_sorted = df.sort([group_col, time_col] if group_col else [time_col])
# Calculate time differences
if group_col:
df_gaps = df_sorted.with_columns(pl.col(time_col).diff().over(group_col).alias("time_diff"))
else:
df_gaps = df_sorted.with_columns(pl.col(time_col).diff().alias("time_diff"))
# If expected_delta provided, filter to gaps exceeding it
if expected_delta is not None:
df_gaps = df_gaps.filter(
(pl.col("time_diff") > expected_delta) | pl.col("time_diff").is_null()
)
# Aggregate
if group_col:
return (
df_gaps.filter(pl.col("time_diff").is_not_null())
.group_by(group_col)
.agg(
pl.len().alias("gap_count"),
pl.col("time_diff").max().alias("max_gap"),
)
.sort(group_col)
)
else:
gaps = df_gaps.filter(pl.col("time_diff").is_not_null())
if gaps.height == 0:
return pl.DataFrame({"gap_count": [0], "max_gap": [None]})
return pl.DataFrame(
{
"gap_count": [gaps.height],
"max_gap": [gaps["time_diff"].max()],
}
)
def per_asset_stats(
df: pl.DataFrame,
time_col: str = "timestamp",
asset_col: str = "symbol",
price_col: str = "close",
volume_col: str | None = "volume",
) -> pl.DataFrame:
"""Calculate per-asset summary statistics.
Args:
df: DataFrame with time series data
time_col: Timestamp column name
asset_col: Asset identifier column name
price_col: Price column for mean calculation
volume_col: Volume column (optional)
Returns:
DataFrame with rows, start, end, avg_price per asset
"""
aggs = [
pl.len().alias("rows"),
pl.col(time_col).min().alias("start"),
pl.col(time_col).max().alias("end"),
pl.col(price_col).mean().alias("avg_price"),
]
if volume_col and volume_col in df.columns:
aggs.append(pl.col(volume_col).mean().alias("avg_volume"))
return df.group_by(asset_col).agg(aggs).sort(asset_col)
# ---------------------------------------------------------------------------
# Modeling pipeline quality gates
# ---------------------------------------------------------------------------
def validate_prices(
df: pl.DataFrame,
price_cols: Sequence[str] = ("open", "high", "low", "close"),
asset_col: str = "symbol",
time_col: str = "timestamp",
) -> list[str]:
"""Check price columns for negative values, infinities, and NaN.
Returns a list of warning/error strings. Empty list = all clean.
"""
issues: list[str] = []
cols_present = [c for c in price_cols if c in df.columns]
for col in cols_present:
n_neg = df.filter(pl.col(col) < 0).height
n_inf = df.filter(pl.col(col).is_infinite()).height
n_nan = df.filter(pl.col(col).is_nan()).height
if n_neg > 0:
# Show which assets have negative prices
neg_assets = df.filter(pl.col(col) < 0).select(asset_col).unique().to_series().to_list()
issues.append(
f"CRITICAL: {col} has {n_neg} negative values "
f"(assets: {neg_assets[:5]}{'...' if len(neg_assets) > 5 else ''})"
)
if n_inf > 0:
issues.append(f"CRITICAL: {col} has {n_inf} infinite values")
if n_nan > 0:
issues.append(f"WARNING: {col} has {n_nan} NaN values")
return issues
def validate_labels(
df: pl.DataFrame,
label_col: str,
max_abs_return: float = 0.5,
) -> list[str]:
"""Check forward return labels for data quality issues.
Args:
df: DataFrame containing the label column
label_col: Name of the forward return column
max_abs_return: Maximum plausible absolute return (e.g., 0.5 = 50%)
Returns list of warning/error strings.
"""
issues: list[str] = []
vals = df[label_col].drop_nulls()
n_inf = vals.filter(vals.is_infinite()).len()
n_nan = vals.filter(vals.is_nan()).len()
n_extreme = vals.filter(vals.abs() > max_abs_return).len()
n_total = vals.len()
if n_inf > 0:
issues.append(f"CRITICAL: {label_col} has {n_inf} infinite values")
if n_nan > 0:
issues.append(f"CRITICAL: {label_col} has {n_nan} NaN values")
if n_extreme > 0:
pct = n_extreme / n_total * 100
issues.append(
f"WARNING: {label_col} has {n_extreme} values with |ret| > {max_abs_return:.0%} "
f"({pct:.2f}% of {n_total:,} rows)"
)
return issues
def validate_features(
df: pl.DataFrame,
feature_cols: Sequence[str],
max_abs_value: float = 1e6,
) -> list[str]:
"""Check feature columns for infinities, all-null, and extreme values.
Args:
df: DataFrame containing feature columns
feature_cols: List of feature column names to validate
max_abs_value: Threshold for flagging extreme values
Returns list of warning/error strings.
"""
issues: list[str] = []
n_rows = df.height
inf_cols = []
null_cols = []
extreme_cols = []
for col in feature_cols:
if col not in df.columns:
continue
series = df[col]
n_null = series.null_count()
non_null = series.drop_nulls()
if n_null == n_rows:
null_cols.append(col)
continue
if non_null.len() > 0:
n_inf = non_null.filter(non_null.is_infinite()).len()
if n_inf > 0:
inf_cols.append((col, n_inf))
n_extreme = non_null.filter(non_null.abs() > max_abs_value).len()
if n_extreme > 0:
extreme_cols.append((col, n_extreme))
if inf_cols:
details = ", ".join(f"{c}({n})" for c, n in inf_cols[:10])
issues.append(f"CRITICAL: {len(inf_cols)} features have infinite values: {details}")
if null_cols:
issues.append(
f"WARNING: {len(null_cols)} features are entirely null: "
f"{null_cols[:10]}{'...' if len(null_cols) > 10 else ''}"
)
if extreme_cols:
details = ", ".join(f"{c}({n})" for c, n in extreme_cols[:10])
issues.append(
f"WARNING: {len(extreme_cols)} features have values |x| > {max_abs_value:.0e}: {details}"
)
return issues
def validate_modeling_inputs(
features_df: pl.DataFrame,
label_df: pl.DataFrame,
feature_cols: Sequence[str],
label_col: str,
join_cols: Sequence[str] = ("timestamp", "symbol"),
price_cols: Sequence[str] = (),
asset_col: str = "symbol",
max_abs_return: float = 0.5,
max_abs_feature: float = 1e6,
fail_on_critical: bool = True,
) -> dict:
"""Run all data quality checks before modeling.
This is the gate between data preparation (labels + features) and
model training. Call this at the start of evaluation notebooks.
Args:
features_df: Feature DataFrame
label_df: Label DataFrame with forward returns
feature_cols: Feature column names to validate
label_col: Forward return column name
join_cols: Columns used to join features and labels
price_cols: Price columns to check (if present in features_df)
asset_col: Asset identifier column name
max_abs_return: Max plausible absolute return for labels
max_abs_feature: Max plausible absolute feature value
fail_on_critical: If True, raise ValueError on CRITICAL issues
Returns:
Dict with 'issues' (list of strings), 'n_critical', 'n_warning'
Raises:
ValueError: If fail_on_critical=True and any CRITICAL issues found
"""
all_issues: list[str] = []
# 1. Price checks (if price columns present)
if price_cols:
all_issues.extend(validate_prices(features_df, price_cols, asset_col=asset_col))
# 2. Label checks
all_issues.extend(validate_labels(label_df, label_col, max_abs_return))
# 3. Feature checks
all_issues.extend(validate_features(features_df, feature_cols, max_abs_feature))
# Summarize
n_critical = sum(1 for i in all_issues if i.startswith("CRITICAL"))
n_warning = sum(1 for i in all_issues if i.startswith("WARNING"))
# Print results
if all_issues:
print(f"Data Quality Gate: {n_critical} CRITICAL, {n_warning} WARNING")
for issue in all_issues:
marker = "[X]" if issue.startswith("CRITICAL") else "[!]"
print(f" {marker} {issue}")
else:
print("Data Quality Gate: ALL CLEAR")
result = {
"issues": all_issues,
"n_critical": n_critical,
"n_warning": n_warning,
}
if fail_on_critical and n_critical > 0:
raise ValueError(
f"Data quality gate FAILED: {n_critical} critical issues. "
f"Fix upstream data before modeling."
)
return result