wip: type system refactor — ColumnProcessor + distance dispatch

This commit is contained in:
PM-pinou
2026-07-24 13:13:51 +08:00
parent 30e48ba282
commit 0ca1e87b46
+162
View File
@@ -0,0 +1,162 @@
"""Unified column processor — dispatches per-type distance computation.
Replaces the if-elif chain in :func:`distance.run_preprocessing_pipeline`
with :data:`~analysis.types.TYPE_REGISTRY` dispatch.
Usage::
from analysis.types import classify_schema
from analysis.column_processor import ColumnProcessor
column_types = classify_schema(lf)
proc = ColumnProcessor(lf, column_types)
lf = proc.compute_distances() # Applies each type's .distance()
lf = proc.normalize() # StandardScaler on numeric dist cols
lf, noise = proc.svd_denoise() # SVD with noise profile
"""
from __future__ import annotations
import numpy as np
import polars as pl
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import TruncatedSVD
# ── Prefixes produced by each type's .distance() ──
_DIST_PREFIXES: tuple[str, ...] = (
'_ip_dist_', '_str_dist_', '_bool_dist_', '_bytes_dist_',
'_ts_dist_', '_enum_dist_', '_raw_',
)
# ── Types that produce numeric distance/raw output (all 8 do) ──
_ALL_TYPES: set[str] = {
"枚举", "字节", "浮点", "整数", "布尔", "字符串", "IPv4", "时间戳",
}
class ColumnProcessor:
"""Processes all columns in a dataset by their registered types.
Groups columns by type, dispatches each group to the corresponding
:class:`~analysis.types.ColumnType` instance in
:data:`~analysis.types.TYPE_REGISTRY`, then chains normalisation
and SVD denoising.
Parameters
----------
lf:
Input LazyFrame with raw data columns.
column_types:
``{col_name: ColumnType}`` mapping from
:func:`~analysis.types.classify_schema`.
"""
def __init__(
self,
lf: pl.LazyFrame,
column_types: dict,
) -> None:
self._lf = lf
self._column_types = column_types
self._feature_cols: list[str] = []
self._noise_profile: dict = {}
# ── public API ──────────────────────────────────────────────────────
@property
def feature_cols(self) -> list[str]:
"""Numeric feature columns available after :meth:`compute_distances`."""
return list(self._feature_cols)
@property
def noise_profile(self) -> dict:
"""SVD noise profile populated by :meth:`svd_denoise`."""
return dict(self._noise_profile)
@property
def lf(self) -> pl.LazyFrame:
"""Current LazyFrame state."""
return self._lf
def compute_distances(self) -> pl.LazyFrame:
"""Group columns by type and dispatch to TYPE_REGISTRY.
Each :class:`~analysis.types.ColumnType` subclass handles all
columns of that type in one call. IP columns with fewer than
2 entries are skipped (they need pairs).
Returns the LazyFrame with distance/raw columns appended.
"""
from analysis.types import TYPE_REGISTRY
# Group columns by type name
groups: dict[str, list[str]] = {}
for col_name, ct in self._column_types.items():
groups.setdefault(ct.name, []).append(col_name)
result = self._lf
# Dispatch each group to its type's distance method
for type_name, cols in groups.items():
type_obj = TYPE_REGISTRY.get(type_name)
if type_obj is None:
continue
# IP distance needs at least 2 columns (pairs)
if type_name == "IPv4" and len(cols) < 2:
continue
result = type_obj.distance(result, cols)
# ── Collect feature columns produced by distance computation ──
schema = result.collect_schema()
self._feature_cols = [
c for c in schema.names()
if any(c.startswith(p) for p in _DIST_PREFIXES)
]
self._lf = result
return result
def normalize(self) -> pl.LazyFrame:
"""Apply StandardScaler normalisation to feature columns.
Uses :func:`analysis.distance.normalize_features` internally.
Requires :meth:`compute_distances` to have been called first.
"""
if not self._feature_cols:
return self._lf
from analysis.distance import normalize_features
self._lf = normalize_features(self._lf, self._feature_cols)
return self._lf
def svd_denoise(self, variance_threshold: float = 0.95) -> tuple[pl.LazyFrame, dict]:
"""SVD noise removal on normalised feature columns.
Uses :func:`analysis.distance.svd_denoise` internally.
Requires :meth:`normalize` to have been called first.
Parameters
----------
variance_threshold:
Fraction of total variance to preserve (0.01.0).
Returns
-------
tuple[pl.LazyFrame, dict]
``(denoised_lf, noise_profile)``.
"""
if not self._feature_cols:
self._noise_profile = {
'kept_components': 0, 'total_components': 0,
'kept_variance': 0.0, 'noise_components': [],
}
return self._lf, self._noise_profile
from analysis.distance import svd_denoise as _svd_denoise
norm_cols = [f'_norm_{c}' for c in self._feature_cols]
self._lf, self._noise_profile = _svd_denoise(
self._lf, norm_cols, variance_threshold,
)
return self._lf, self._noise_profile