""" IP distance functions for Polars LazyFrames. Provides subnet-mask distance and Haversine geographic distance between pairs of IP columns. All distances are normalised to [0, 1]; invalid / non-geolocatable IPs produce ``NaN``. Typical usage:: from analysis.distance import ip_distance lf = pl.scan_csv("flows.csv") lf = ip_distance(lf, ["src_ip", "dst_ip"]) lf = ip_distance(lf, ["proxy_ip", "origin_ip"], subnet_masks=[16, 24]) """ from __future__ import annotations import math from typing import Optional import polars as pl from analysis.geoip import ip_to_int, lookup # --------------------------------------------------------------------------- # Earth constant # --------------------------------------------------------------------------- _HALF_EARTH_CIRCUMFERENCE_KM = 20_037.508 # equatorial / 2 # --------------------------------------------------------------------------- # Core distance helpers # --------------------------------------------------------------------------- def _subnet_mask_distance(ip1: Optional[str], ip2: Optional[str], mask: int) -> float: """Normalised subnet-mask distance between two IP strings. Computes ``abs(int(ip1)/2**mask - int(ip2)/2**mask) / 2**(32-mask)``. Returns ``NaN`` when either IP is ``None`` or unparseable. """ if ip1 is None or ip2 is None: return float("nan") if not isinstance(ip1, str): ip1 = str(ip1) if not isinstance(ip2, str): ip2 = str(ip2) int1 = ip_to_int(ip1) int2 = ip_to_int(ip2) if int1 is None or int2 is None: return float("nan") divisor = 2.0 ** mask max_val = 2.0 ** (32 - mask) return abs(int1 / divisor - int2 / divisor) / max_val def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: """Great-circle distance (km) via the Haversine formula.""" dlat = math.radians(lat2 - lat1) dlon = math.radians(lon2 - lon1) a = ( math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2 ) return 6371.0 * 2.0 * math.asin(math.sqrt(a)) def _geo_distance(ip1: Optional[str], ip2: Optional[str]) -> float: """Normalised [0, 1] geographic distance between two IP strings. Uses ``analysis.geoip.lookup()`` to obtain lat/lon, then Haversine. Returns ``NaN`` when either IP cannot be geolocated. """ if ip1 is None or ip2 is None: return float("nan") if not isinstance(ip1, str): ip1 = str(ip1) if not isinstance(ip2, str): ip2 = str(ip2) geo1 = lookup(ip1) geo2 = lookup(ip2) if geo1 is None or geo2 is None: return float("nan") km = _haversine_km(geo1["lat"], geo1["lon"], geo2["lat"], geo2["lon"]) return km / _HALF_EARTH_CIRCUMFERENCE_KM # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def ip_distance( lf: pl.LazyFrame, ip_columns: list[str], subnet_masks: Optional[list[int]] = None, ) -> pl.LazyFrame: """Append IP distance columns to a LazyFrame. For each **consecutive pair** of *ip_columns* (``ip_columns[0]`` vs ``ip_columns[1]``, ``ip_columns[2]`` vs ``ip_columns[3]``, …) computes: * one subnet-mask distance per entry in *subnet_masks* * one Haversine geographic distance All distances are normalised to [0, 1]. Invalid IPs or IPs not found in the GeoIP database produce ``NaN``. Parameters ---------- lf: LazyFrame containing the IP columns. ip_columns: List of IP column names (used in consecutive pairs). subnet_masks: Subnet masks for network distance (default ``[24, 28]``). Returns ------- pl.LazyFrame Input frame with ``_ip_dist_0``, ``_ip_dist_1``, … columns appended. Examples -------- >>> lf = pl.LazyFrame({ ... "src_ip": ["8.8.8.8", "1.1.1.1"], ... "dst_ip": ["8.8.4.4", "1.0.0.1"], ... }) >>> result = ip_distance(lf, ["src_ip", "dst_ip"], subnet_masks=[24]) >>> result.collect().columns ['src_ip', 'dst_ip', '_ip_dist_0', '_ip_dist_1'] """ if subnet_masks is None: subnet_masks = [24, 28] result = lf dist_idx = 0 # Walk consecutive pairs for i in range(0, len(ip_columns) - 1, 2): col_a = ip_columns[i] col_b = ip_columns[i + 1] # -- subnet-mask distances -- for mask in subnet_masks: name = f"_ip_dist_{dist_idx}" result = result.with_columns( pl.struct([col_a, col_b]) .map_elements( lambda s, _mask=mask, _ca=col_a, _cb=col_b: ( _subnet_mask_distance(s[_ca], s[_cb], _mask) ), return_dtype=pl.Float64, ) .alias(name) ) dist_idx += 1 # -- geographic distance -- name = f"_ip_dist_{dist_idx}" result = result.with_columns( pl.struct([col_a, col_b]) .map_elements( lambda s, _ca=col_a, _cb=col_b: _geo_distance(s[_ca], s[_cb]), return_dtype=pl.Float64, ) .alias(name) ) dist_idx += 1 return result # --------------------------------------------------------------------------- # String distance # --------------------------------------------------------------------------- def _is_enum_like(s: pl.Series) -> bool: """Check if ≥50% of rows have values appearing ≥2 times.""" total = len(s) if total == 0: return False non_null = s.drop_nulls() if len(non_null) == 0: return False vc = non_null.value_counts() if 'count' not in vc.columns: return False duplicated = vc.filter(pl.col('count') >= 2) if len(duplicated) == 0: return False duplicated_count: int = duplicated['count'].sum() # type: ignore[union-attr] return duplicated_count >= 0.5 * total def _levenshtein_normalized(s1: str | None, s2: str) -> float: """Normalised Levenshtein distance between *s1* and *s2*. Empty or ``None`` values return 1.0 (maximum distance). """ if s1 is None or s1 == '': return 1.0 if s2 is None or s2 == '': return 1.0 s1_str = str(s1) s2_str = str(s2) max_len = max(len(s1_str), len(s2_str)) if max_len == 0: return 0.0 import Levenshtein return Levenshtein.distance(s1_str, s2_str) / max_len def string_distance(lf: pl.LazyFrame, str_columns: list[str]) -> pl.LazyFrame: """Compute string distance columns. For each column in *str_columns*: * If **≥50%** of rows have values appearing ≥2 times, the column is treated as an enum: 0 when the value equals the mode, 1 otherwise. * Otherwise, the normalised Levenshtein distance to the mode is computed for each row. Empty or ``None`` values always produce the maximum distance (1.0). Parameters ---------- lf: Input LazyFrame. str_columns: List of string column names to compute distances for. Returns ------- pl.LazyFrame Input LazyFrame extended with ``_str_dist_0``, ``_str_dist_1``, ... columns (one per entry in *str_columns*). """ df = lf.collect(streaming=True) dist_exprs: list[pl.Expr] = [] for idx, col in enumerate(str_columns): if col not in df.columns: continue series = df[col].cast(pl.Utf8) enum_like = _is_enum_like(series) non_null = series.drop_nulls() if len(non_null) > 0: mode_val = non_null.value_counts().sort('count', descending=True).row(0)[0] mode_str = str(mode_val) else: mode_str = '' col_name = f'_str_dist_{idx}' if enum_like: dist_exprs.append( pl.when(pl.col(col).is_null()) .then(pl.lit(1.0)) .when(pl.col(col).cast(pl.Utf8) == pl.lit(mode_str)) .then(pl.lit(0.0)) .otherwise(pl.lit(1.0)) .alias(col_name) ) else: # Capture in closure for map_elements _mode = mode_str dist_exprs.append( pl.col(col).map_elements( lambda v: _levenshtein_normalized(v, _mode), return_dtype=pl.Float64, ).alias(col_name) ) return lf.with_columns(dist_exprs) # --------------------------------------------------------------------------- # Boolean distance # --------------------------------------------------------------------------- def bool_distance(lf: pl.LazyFrame, bool_columns: list[str]) -> pl.LazyFrame: """Compute boolean distance columns. For each column in *bool_columns*, each row gets: 0 if its value matches the column mode, 1 otherwise. ``None`` values always produce 1.0 (maximum distance). Parameters ---------- lf: Input LazyFrame. bool_columns: List of boolean column names to compute distances for. Returns ------- pl.LazyFrame Input LazyFrame extended with ``_bool_dist_0``, ``_bool_dist_1``, ... columns (one per entry in *bool_columns*). """ df = lf.collect(streaming=True) dist_exprs: list[pl.Expr] = [] for idx, col in enumerate(bool_columns): if col not in df.columns: continue series = df[col] non_null = series.drop_nulls() col_name = f'_bool_dist_{idx}' if len(non_null) == 0: dist_exprs.append(pl.lit(0.0).alias(col_name)) else: mode_val = non_null.value_counts().sort('count', descending=True).row(0)[0] dist_exprs.append( pl.when(pl.col(col).is_null()) .then(pl.lit(1.0)) .when(pl.col(col) == pl.lit(mode_val)) .then(pl.lit(0.0)) .otherwise(pl.lit(1.0)) .alias(col_name) ) return lf.with_columns(dist_exprs) # --------------------------------------------------------------------------- # Bytes Hamming distance # --------------------------------------------------------------------------- _HEX_POPCOUNT: dict[str, int] = { '0': 0, '1': 1, '2': 1, '3': 2, '4': 1, '5': 2, '6': 2, '7': 3, '8': 1, '9': 2, 'a': 2, 'b': 3, 'c': 2, 'd': 3, 'e': 3, 'f': 4, 'A': 2, 'B': 3, 'C': 2, 'D': 3, 'E': 3, 'F': 4, } def _hex_popcount_normalized(val: str | None) -> float: """Return the proportion of 1-bits in a hex byte string (0.0–1.0). This is the Hamming distance from the all-zero bit string, normalised by total bits. Returns ``NaN`` for null or non-hex input. """ if val is None: return float("nan") s = str(val).replace(' ', '').replace('\t', '').replace('\n', '') if not s: return 0.0 total_ones = 0 for ch in s: ones = _HEX_POPCOUNT.get(ch) if ones is None: return float("nan") # non-hex character total_ones += ones total_bits = len(s) * 4 return total_ones / total_bits def bytes_distance( lf: pl.LazyFrame, byte_columns: list[str], ) -> pl.LazyFrame: """Append normalised Hamming-distance columns for hex byte columns. Each byte column is converted from hex to binary, and the proportion of 1-bits (Hamming distance from the all-zero bit vector) is computed as a ``_bytes_dist_N`` Float64 column normalised to [0, 1]. Non-hex / null values produce ``NaN``. Parameters ---------- lf: LazyFrame containing the byte columns. byte_columns: Column names whose values are hex byte strings (with optional whitespace separators, e.g. ``"A3 F2"``). Returns ------- pl.LazyFrame Input frame with ``_bytes_dist_0``, ``_bytes_dist_1``, … appended. Columns not present in *lf* are silently skipped. Examples -------- >>> df = pl.DataFrame({"payload": ["A3F2", "FFFF", None]}) >>> result = bytes_distance(df.lazy(), ["payload"]) >>> result.collect() shape: (3, 2) ┌─────────┬────────────────┐ │ payload ┆ _bytes_dist_0 │ │ --- ┆ --- │ │ str ┆ f64 │ ╞═════════╪════════════════╡ │ A3F2 ┆ 0.625 │ │ FFFF ┆ 1.0 │ │ null ┆ NaN │ └─────────┴────────────────┘ """ existing = set(lf.collect_schema().names()) result = lf for i, col_name in enumerate(byte_columns): if col_name not in existing: continue result = result.with_columns( pl.col(col_name) .cast(pl.Utf8) .map_elements(_hex_popcount_normalized, return_dtype=pl.Float64) .alias(f'_bytes_dist_{i}') ) return result # --------------------------------------------------------------------------- # Timestamp FFT phase distance # --------------------------------------------------------------------------- import numpy as np # ── threshold: minimum fraction of repeated intervals to use FFT path ── _FFT_PERIODICITY_THRESHOLD = 0.05 # ── minimum samples before FFT is attempted ── _FFT_MIN_SAMPLES = 4 def _parse_timestamp_to_epoch(series: pl.Series) -> pl.Series: """Convert a timestamp series to Unix epoch seconds (float64). Handles int/float (assumed already epoch), pl.Datetime/pl.Date (ns→seconds), and str/pl.Utf8 (strptime→ns→seconds, with direct float-cast fallback on parse failure). """ dtype = series.dtype if dtype in (pl.Utf8, pl.String): try: parsed = series.str.strptime(pl.Datetime, format=None, strict=False) except Exception: return series.cast(pl.Float64) return parsed.cast(pl.Int64).cast(pl.Float64) / 1_000_000_000.0 elif dtype in (pl.Datetime, pl.Date): return series.cast(pl.Int64).cast(pl.Float64) / 1_000_000_000.0 else: return series.cast(pl.Float64) def _compute_fft_distances(values: np.ndarray) -> np.ndarray: """Compute per-row phase distances for a 1-D array. Sorts *values*, checks for periodicity, then either: * FFT path: ``np.fft.fft()`` → ``np.angle()``, mapped back to original (unsorted) row order via rank lookup. * Statistical fallback: interval z-scores mapped to row order. Returns an ``(n,)`` float64 array, normalised to zero-mean unit-variance. """ n = len(values) if n < _FFT_MIN_SAMPLES: return np.full(n, np.nan, dtype=np.float64) # Sort once; record rank→position mapping for unsorting later sorted_order = np.argsort(values) ranks = np.empty(n, dtype=np.int64) ranks[sorted_order] = np.arange(n) sorted_vals = values[sorted_order] # ── periodicity check ────────────────────────────────────────── intervals = np.diff(sorted_vals) unique_int, counts = np.unique( np.round(intervals, 6), return_counts=True ) repeat_ratio = float(np.sum(counts[counts > 1])) / max(len(intervals), 1) if repeat_ratio >= _FFT_PERIODICITY_THRESHOLD: # ── FFT phase path ───────────────────────────────────────── centered = sorted_vals - np.mean(sorted_vals) fft_result = np.fft.fft(centered) phases = np.angle(fft_result) dist_values = phases[ranks] else: # ── statistical fallback ─────────────────────────────────── mean_int = float(np.mean(intervals)) std_int = float(np.std(intervals)) if std_int > 1e-10: z_scores = (intervals - mean_int) / std_int else: z_scores = np.zeros_like(intervals) # Row at sorted position r gets the z-score of interval r-1 # (preceding interval); row at position 0 reuses last interval. dist_values = np.empty(n, dtype=np.float64) for r in range(n): if r > 0: dist_values[ranks[r]] = z_scores[r - 1] else: dist_values[ranks[0]] = ( z_scores[-1] if n > 1 else 0.0 ) # Normalise to zero-mean unit-variance d_std = float(np.std(dist_values)) d_mean = float(np.mean(dist_values)) if d_std > 1e-10: dist_values = (dist_values - d_mean) / d_std else: dist_values = dist_values - d_mean return dist_values def timestamp_fft_distance( lf: pl.LazyFrame, ts_columns: list[str] ) -> pl.LazyFrame: """Append FFT-based phase-distance columns for timestamp columns. For each column in *ts_columns* that exists in the schema: 1. Parse values to Unix epoch float seconds (handles int, float, datetime strings, and ``pl.Datetime``). 2. Collect the column to numpy, sort, then run ``np.fft.fft`` followed by ``np.angle`` to extract per-row phase values. 3. If fewer than 5 % of timestamp intervals repeat (weak periodicity), fall back to statistical features derived from interval mean, standard deviation, and z-scores. Columns are appended as ``_ts_dist_0``, ``_ts_dist_1``, … in the order *ts_columns* are supplied. Rows whose timestamp is null receive ``NaN``. Parameters ---------- lf : pl.LazyFrame The input LazyFrame with at least one timestamp column. ts_columns : list[str] Column names to process. Returns ------- pl.LazyFrame A new LazyFrame with ``_ts_dist_N`` columns appended. Examples -------- >>> import polars as pl >>> from analysis.distance import timestamp_fft_distance >>> df = pl.DataFrame({ ... 'ts': [0, 60, 120, 180, 240, 300], ... }) >>> result = timestamp_fft_distance(df.lazy(), ['ts']) >>> result.collect().columns ['ts', '_ts_dist_0'] """ schema = lf.collect_schema() available = [c for c in ts_columns if c in schema.names()] if not available: return lf # Collect all needed columns once df = lf.select(available).collect(streaming=True) n_rows = len(df) dist_arrays: list[np.ndarray] = [] for col in available: series = df[col] ts_numeric = _parse_timestamp_to_epoch(series) # Identify valid (non-null) rows valid = ~ts_numeric.is_null() valid_indices = np.where(valid.to_numpy())[0] values = ts_numeric.filter(valid).to_numpy().astype(np.float64) if len(values) < _FFT_MIN_SAMPLES: continue phase_values = _compute_fft_distances(values) # Build full-length array; invalid rows stay NaN full_dist = np.full(n_rows, np.nan, dtype=np.float64) full_dist[valid_indices] = phase_values dist_arrays.append(full_dist) if not dist_arrays: return lf # Re-materialise the original frame and bolt on distance columns result_df = lf.collect(streaming=True) for i, arr in enumerate(dist_arrays): result_df = result_df.with_columns( pl.Series(f'_ts_dist_{i}', arr) ) return result_df.lazy() # --------------------------------------------------------------------------- # Normalization — StandardScaler (step 2 of preprocessing pipeline) # --------------------------------------------------------------------------- from sklearn.preprocessing import StandardScaler # noqa: E402 def normalize_features(lf: pl.LazyFrame, feature_cols: list[str]) -> pl.LazyFrame: """Apply StandardScaler normalisation to numeric feature columns. Collects *feature_cols* into a numpy array, replaces ``NaN`` with zero, centres to μ=0 σ=1 via ``sklearn.preprocessing.StandardScaler``, then returns the result as *lf* extended with ``_norm_*`` prefixed columns. Parameters ---------- lf: LazyFrame containing *feature_cols*. feature_cols: Numeric column names to normalise. Columns not present in *lf* are silently skipped; non-numeric columns raise an error. Returns ------- pl.LazyFrame Input frame with additional ``_norm_{col}`` columns. Memory ------ Peak memory ≈ collected numpy matrix + scaler workspace (well under the 800 MB budget for typical inputs). """ available = [c for c in feature_cols if c in lf.collect_schema().names()] if not available: return lf df = lf.select(available).collect(streaming=True) mat = df.to_numpy().astype(np.float64) mat = np.nan_to_num(mat, nan=0.0) scaler = StandardScaler() scaled = scaler.fit_transform(mat) # Build new columns with _norm_ prefix df_result = lf.collect(streaming=True) for i, col in enumerate(available): df_result = df_result.with_columns( pl.Series(f'_norm_{col}', scaled[:, i]) ) return df_result.lazy() # --------------------------------------------------------------------------- # SVD noise removal (step 3 of preprocessing pipeline) # --------------------------------------------------------------------------- from sklearn.decomposition import TruncatedSVD # noqa: E402 def svd_denoise( lf: pl.LazyFrame, feature_cols: list[str], variance_threshold: float = 0.95, ) -> tuple[pl.LazyFrame, dict]: """Remove noise from numeric features via TruncatedSVD. 1. Fits TruncatedSVD on *feature_cols*. 2. Keeps *k* components where cumulative explained variance ``trace(S[:k]) / trace(S) ≥ variance_threshold`` (default 0.95). 3. Inverse-transforms to reconstruct the denoised matrix. 4. **Replaces** the original *feature_cols* with denoised values. The noise components (removed by SVD) are returned as a dict for display, NOT as new feature columns. Parameters ---------- lf: LazyFrame containing *feature_cols*. feature_cols: Numeric column names to denoise. These columns are replaced in-place by their denoised reconstructions. variance_threshold: Fraction of total variance to preserve (0.0–1.0). Default 0.95 keeps 95 % of signal and treats 5 % as noise. Returns ------- tuple[pl.LazyFrame, dict] ``(denoised_lf, noise_profile)`` where *noise_profile* contains: - ``kept_components`` : int — number of components kept - ``total_components`` : int — max possible components - ``kept_variance`` : float — fraction of variance kept - ``noise_components`` : list[dict] — per-component noise info (component index, explained variance, top-5 contributing features) """ noise_profile = { 'kept_components': 0, 'total_components': 0, 'kept_variance': 0.0, 'noise_components': [], } available = [c for c in feature_cols if c in lf.collect_schema().names()] if not available: return lf, noise_profile df = lf.select(available).collect(streaming=True) mat = df.to_numpy().astype(np.float64) mat = np.nan_to_num(mat, nan=0.0) n, d = mat.shape max_components = min(n, d - 1) if max_components < 1: return lf, noise_profile from sklearn.decomposition import TruncatedSVD svd = TruncatedSVD(n_components=max_components, random_state=42) _ = svd.fit_transform(mat) total_variance = float(np.sum(svd.explained_variance_)) if total_variance <= 1e-10: return lf, noise_profile cumsum = np.cumsum(svd.explained_variance_) for k in range(1, max_components + 1): if cumsum[k - 1] / total_variance >= variance_threshold: break else: k = max_components # Re-fit with chosen k svd_k = TruncatedSVD(n_components=k, random_state=42) reduced = svd_k.fit_transform(mat) reconstructed = svd_k.inverse_transform(reduced) # --- Build noise profile (removed components) --- # The "noise" is the variance in components k..max_components kept_var_ratio = cumsum[k - 1] / total_variance if k > 0 else 0.0 noise_profile = { 'kept_components': k, 'total_components': max_components, 'kept_variance': round(float(kept_var_ratio), 4), } # Per-component noise info for removed components (k and beyond) noise_info = [] for comp_idx in range(k, min(k + 10, max_components)): loading = np.abs(svd.components_[comp_idx]) top5_idx = np.argsort(loading)[-5:][::-1] top5 = [ {'feature': available[i], 'strength': round(float(loading[i]), 4)} for i in top5_idx ] noise_info.append({ 'component': int(comp_idx + 1), 'explained_var_ratio': round(float(svd.explained_variance_ratio_[comp_idx]) * 100, 3), 'top_features': top5, }) noise_profile['noise_components'] = noise_info # --- Replace original columns with denoised values --- df_result = lf.collect(streaming=True) for i, col in enumerate(available): df_result = df_result.with_columns( pl.Series(col, reconstructed[:, i]) ) return df_result.lazy(), noise_profile # --------------------------------------------------------------------------- # Master preprocessing pipeline # --------------------------------------------------------------------------- def run_preprocessing_pipeline( lf: pl.LazyFrame, column_types: dict | None = None, ) -> pl.LazyFrame: """Full preprocessing pipeline for UMAP / clustering. Steps (executed sequentially): 1. **Column type detection** — uses :func:`analysis.type_classifier.classify_schema` unless *column_types* is explicitly provided. 2. **Distance computation** — IP distance, string distance, bool distance, bytes distance, and timestamp FFT distance computed per-column-type and appended as ``_ip_dist_*``, ``_str_dist_*``, ``_bool_dist_*``, ``_bytes_dist_*``, ``_ts_dist_*`` columns. 3. **Normalisation** — :func:`normalize_features` (StandardScaler → μ=0, σ=1). 4. **SVD denoise** — :func:`svd_denoise` (TruncatedSVD keeping 95 % variance). Parameters ---------- lf: Input LazyFrame with raw data columns. column_types: Optional ``{col_name: DataType}`` mapping. When ``None``, :func:`analysis.type_classifier.classify_schema` is called to auto-detect types. Returns ------- pl.LazyFrame LazyFrame with only the SVD-denoised feature columns (``_svd__norm__*_dist_*`` etc.) plus original row anchors. """ if column_types is None: from analysis.types import classify_schema column_types = classify_schema(lf) ip_cols: list[str] = [] str_cols: list[str] = [] bool_cols: list[str] = [] byte_cols: list[str] = [] ts_cols: list[str] = [] float_cols: list[str] = [] int_cols: list[str] = [] for col_name, ct in column_types.items(): if ct.name == "IPv4": ip_cols.append(col_name) elif ct.name == "字符串": str_cols.append(col_name) elif ct.name == "布尔": bool_cols.append(col_name) elif ct.name == "字节": byte_cols.append(col_name) elif ct.name == "时间戳": ts_cols.append(col_name) elif ct.name in ("浮点", "整数"): # Float/Int are kept as direct numeric features if ct.name == "浮点": float_cols.append(col_name) else: int_cols.append(col_name) result = lf # -- Step 2: Distance computation -- if ip_cols and len(ip_cols) >= 2: result = ip_distance(result, ip_cols) if str_cols: result = string_distance(result, str_cols) if bool_cols: result = bool_distance(result, bool_cols) if byte_cols: result = bytes_distance(result, byte_cols) if ts_cols: result = timestamp_fft_distance(result, ts_cols) # Collect numeric feature columns present after distance computation schema = result.collect_schema() all_dist_cols = [ c for c in schema.names() if (c.startswith('_ip_dist_') or c.startswith('_str_dist_') or c.startswith('_bool_dist_') or c.startswith('_bytes_dist_') or c.startswith('_ts_dist_')) ] # Also include raw FLOAT/INT columns as direct features numeric_raw = [ c for c in (float_cols + int_cols) if c in schema.names() ] feature_cols = all_dist_cols + numeric_raw if not feature_cols: return result # -- Step 3: Normalisation -- result = normalize_features(result, feature_cols) # -- Step 4: SVD denoise (REPLACES noisy features with cleaned ones) -- norm_cols = [f'_norm_{c}' for c in feature_cols] result, noise_profile = svd_denoise(result, norm_cols) return result, noise_profile # --------------------------------------------------------------------------- # UMAP dimensionality reduction (3D → 2D fallback) # --------------------------------------------------------------------------- def umap_reduce( lf: pl.LazyFrame, feature_cols: list[str], n_components: int = 3, ) -> tuple[pl.LazyFrame, dict]: """UMAP dimensionality reduction with automatic 3D→2D fallback. Trains on a ≤10K-row sample (when *lf* exceeds that limit), then batch-transforms the remaining rows in 1K-row batches. If ``n_components=3`` fails (e.g. too few rows or numerical issues), falls back to ``n_components=2`` automatically. Parameters ---------- lf: LazyFrame containing the numeric feature columns. feature_cols: Numeric column names to use for UMAP projection. Columns not present in *lf* are silently skipped. n_components: Target dimensionality (2 or 3). Default 3 with fallback to 2. Returns ------- tuple[pl.LazyFrame, dict] - LazyFrame: input data extended with ``_umap_x``, ``_umap_y`` (and ``_umap_z`` when ``n_components ≥ 3``). - dict: metadata with keys ``n_components`` and ``reducer``. """ import numpy as np from sklearn.preprocessing import StandardScaler import umap import logging logger = logging.getLogger(__name__) MAX_TRAIN = 10000 BATCH_SIZE = 1000 available = [c for c in feature_cols if c in lf.collect_schema().names()] if not available: return lf, {'n_components': 0, 'reducer': 'umap', 'error': 'no feature columns available'} df = lf.collect(streaming=True) n_total = len(df) if n_total < 2: return lf, {'n_components': 0, 'reducer': 'umap', 'error': 'too few rows'} mat = df.select(available).to_numpy().astype(np.float64) mat = np.nan_to_num(mat, nan=0.0, posinf=0.0, neginf=0.0) scaler = StandardScaler() if n_total > MAX_TRAIN: rng = np.random.RandomState(42) idx_sample = rng.choice(n_total, size=MAX_TRAIN, replace=False) mat_sample = mat[idx_sample] scaler.fit(mat_sample) mat_sample_scaled = np.nan_to_num(scaler.transform(mat_sample), nan=0.0) else: scaler.fit(mat) mat_sample_scaled = np.nan_to_num(scaler.transform(mat), nan=0.0) # ── Train UMAP; fallback 3→2 on failure ── reducer = None coords = None effective_n = n_components # Try requested n_components first, then fall back to 2 (no duplicates) attempts = sorted(set((n_components, 2)), reverse=True) for attempt_n in attempts: if attempt_n != n_components: logger.warning(f'UMAP {n_components}D failed, falling back to 2D') effective_n = attempt_n try: reducer = umap.UMAP( n_components=effective_n, random_state=42, n_neighbors=15, min_dist=0.1, metric='euclidean', ) if n_total > MAX_TRAIN: reducer.fit(mat_sample_scaled) else: coords = reducer.fit_transform(mat_sample_scaled) break except Exception as e: if attempt_n == 2: raise logger.warning(f'UMAP {attempt_n}D failed: {e}') # ── Batch transform if dataset was larger than training sample ── if n_total > MAX_TRAIN and reducer is not None: coords_list: list[np.ndarray] = [] for start in range(0, n_total, BATCH_SIZE): end = min(start + BATCH_SIZE, n_total) mat_batch = scaler.transform(mat[start:end]) mat_batch = np.nan_to_num(mat_batch, nan=0.0, posinf=0.0, neginf=0.0) coords_list.append(reducer.transform(mat_batch)) coords = np.vstack(coords_list) if coords is None: return lf, {'n_components': 0, 'reducer': 'umap', 'error': 'UMAP produced no output'} # ── Build result LazyFrame with coordinate columns ── coord_series = [ pl.Series('_umap_x', coords[:, 0]), pl.Series('_umap_y', coords[:, 1]), ] if effective_n >= 3 and coords.shape[1] >= 3: coord_series.append(pl.Series('_umap_z', coords[:, 2])) df_result = df.with_columns(coord_series) metadata: dict = { 'n_components': effective_n, 'reducer': 'umap', } return df_result.lazy(), metadata # --------------------------------------------------------------------------- # Per-cluster SVD feature extraction # --------------------------------------------------------------------------- def cluster_svd_extract( lf: pl.LazyFrame, labels: list[int], feature_cols: list[str], ) -> dict[int, dict]: """Per-cluster SVD-based distinguishing feature extraction. For each cluster label: 1. Extracts rows belonging to that cluster. 2. Fits ``TruncatedSVD(n_components=min(N, len(feature_cols)))``. 3. The absolute loadings on the first SVD component serve as *feature_strength* — higher values mean more distinguishing. Returns a mapping ``{cluster_label: {...}}`` where each value is:: { 'features': [...], # top-10 feature names by strength 'explained_variance_ratio': [...], # per-component ratios 'feature_strength': [...], # per-feature loading magnitude } Parameters ---------- lf: LazyFrame with rows aligned to *labels*. labels: Cluster labels (same length as *lf* row count, ``-1`` = noise). feature_cols: Numeric column names to analyse. Returns ------- dict[int, dict] Per-cluster SVD extraction results. """ import numpy as np from sklearn.decomposition import TruncatedSVD available = [c for c in feature_cols if c in lf.collect_schema().names()] if not available: return {} df = lf.select(available).collect(streaming=True) mat = df.to_numpy().astype(np.float64) mat = np.nan_to_num(mat, nan=0.0, posinf=0.0, neginf=0.0) if len(labels) != len(mat): labels = labels[:len(mat)] unique_labels = sorted(set(labels)) result: dict[int, dict] = {} for label in unique_labels: mask = np.array(labels) == label n = int(mask.sum()) if n < 2: continue cluster_mat = mat[mask] n_comp = min(n, len(available)) svd = TruncatedSVD(n_components=n_comp, random_state=42) svd.fit(cluster_mat) # First component loadings → feature strength if n_comp > 0 and svd.components_.shape[0] > 0: feature_strength = np.abs(svd.components_[0]).astype(np.float64) else: feature_strength = np.zeros(len(available), dtype=np.float64) total_strength = float(feature_strength.sum()) if total_strength > 1e-10: feature_strength = feature_strength / total_strength top_indices = np.argsort(-feature_strength) top_k = min(10, len(available)) result[int(label)] = { 'features': [available[i] for i in top_indices[:top_k]], 'explained_variance_ratio': svd.explained_variance_ratio_.tolist(), 'feature_strength': feature_strength.tolist(), } return result