cleanup: consolidate TLS_HEX_MAP, extract constants, remove dead code, add traceback logging
3.1 Unified TLS_HEX_MAP: defined once in type_classifier.py, imported in globe.py and clustering.py 3.2 Created analysis/constants.py: centralized RANDOM_SEED, MAX_SAMPLE_ROWS, UMAP constants, GLOBE_MAX_ROWS, etc. 3.3 Removed 14 pointless try/except:raise dead code blocks across tools/ and views/ 3.4 Added logger.error(traceback) to all bare except Exception: blocks in tools/ and views/ 3.5 Moved inline import traceback/asyncio to top-level imports in features.py, clustering.py, pipeline.py 3.6 Removed auto_profile_module side-effect imports from 6 modules
This commit is contained in:
@@ -0,0 +1,96 @@
|
|||||||
|
"""Centralized constants for the TianXuan analysis module.
|
||||||
|
|
||||||
|
All magic numbers from analysis/tools/ and analysis/views/ are defined here
|
||||||
|
so they can be updated in one place. Import with::
|
||||||
|
|
||||||
|
from analysis.constants import RANDOM_SEED, MAX_SAMPLE_ROWS, ...
|
||||||
|
"""
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Clustering / UMAP
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MAX_SAMPLE_ROWS = 50000
|
||||||
|
"""Maximum rows for clustering sample (downsampling threshold)."""
|
||||||
|
|
||||||
|
MAX_DISTRIBUTION_SAMPLE = 10000
|
||||||
|
"""Default sample size for distribution exploration and scoring."""
|
||||||
|
|
||||||
|
UMAP_TRAIN_SAMPLE = 10000
|
||||||
|
"""Max rows used to train a UMAP reducer in extract_features."""
|
||||||
|
|
||||||
|
UMAP_BATCH_SIZE = 1000
|
||||||
|
"""Batch size for UMAP transform when dataset exceeds UMAP_TRAIN_SAMPLE."""
|
||||||
|
|
||||||
|
LOW_MEMORY_THRESHOLD_GB = 2
|
||||||
|
"""If available memory < this (in GiB), enable low-memory clustering path."""
|
||||||
|
|
||||||
|
LOW_MEMORY_THRESHOLD_ROWS = 100000
|
||||||
|
"""Row count above which low-memory downsampling is triggered."""
|
||||||
|
|
||||||
|
RANDOM_SEED = 42
|
||||||
|
"""Default random seed for all sklearn/numpy operations."""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Globe
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
GLOBE_MAX_ROWS = 300
|
||||||
|
"""Max rows to load per run for 3D globe visualization."""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Filter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
FILTER_MAX_ROWS = 5
|
||||||
|
"""Maximum visible filter rows in the manual analysis UI (deprecated / reserved)."""
|
||||||
|
|
||||||
|
MAX_COLUMNS_PROFILE = 200
|
||||||
|
"""Max columns to include in a dataset profile summary."""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# File upload limits
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
FILE_UPLOAD_MAX_SIZE = 500 * 1024 * 1024 # 500 MB
|
||||||
|
"""Maximum size for a single uploaded file."""
|
||||||
|
|
||||||
|
TOTAL_UPLOAD_MAX_SIZE = 50 * 1024 * 1024 * 1024 # 50 GB
|
||||||
|
"""Maximum total upload size across all files."""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool limits (numeric type tuples used across tools/)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
NUMERIC_DTYPES: tuple[type[pl.DataType], ...] = (
|
||||||
|
pl.Int8, pl.Int16, pl.Int32, pl.Int64,
|
||||||
|
pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64,
|
||||||
|
pl.Float32, pl.Float64,
|
||||||
|
)
|
||||||
|
"""Polars numeric data types — used for column selection in clustering,
|
||||||
|
entity aggregation, anomaly detection, and feature extraction."""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Diagnostics / Validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MAX_VALIDATE_SAMPLE = 1000
|
||||||
|
"""Max rows sampled during validate_data."""
|
||||||
|
|
||||||
|
MAX_DIAGNOSE_SAMPLE = 5000
|
||||||
|
"""Max rows sampled for clustering diagnosis (SVD)."""
|
||||||
|
|
||||||
|
MAX_CLUSTER_FEATURES = 50
|
||||||
|
"""Feature count threshold above which TruncatedSVD is applied."""
|
||||||
|
|
||||||
|
CLUSTER_MAX_FEATURES_TOP = 10
|
||||||
|
"""Max feature columns selected by auto-detection in clustering tools."""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Globe / Flow extraction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
GLOBE_FLOW_MAX_ROWS = 300
|
||||||
|
"""Alias for GLOBE_MAX_ROWS — max rows loaded for flow extraction."""
|
||||||
@@ -771,8 +771,3 @@ def load_csv_directory(
|
|||||||
total_row_count = min(total_row_count, head)
|
total_row_count = min(total_row_count, head)
|
||||||
|
|
||||||
return merged_lf, merged_schema, total_row_count, len(paths), memory_mb
|
return merged_lf, merged_schema, total_row_count, len(paths), memory_mb
|
||||||
|
|
||||||
|
|
||||||
# Auto-profile all public functions in this module
|
|
||||||
from analysis.profile_util import auto_profile_module # noqa: E402
|
|
||||||
auto_profile_module(__name__)
|
|
||||||
|
|||||||
@@ -331,8 +331,3 @@ def profile_dataset(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return truncate_profile(result, max_kb=max_kb)
|
return truncate_profile(result, max_kb=max_kb)
|
||||||
|
|
||||||
|
|
||||||
# Auto-profile all public functions in this module
|
|
||||||
from analysis.profile_util import auto_profile_module # noqa: E402
|
|
||||||
auto_profile_module(__name__)
|
|
||||||
|
|||||||
@@ -400,8 +400,3 @@ def describe_cluster(
|
|||||||
def _signed(value: float) -> str:
|
def _signed(value: float) -> str:
|
||||||
"""Return a signed string representation, e.g. ``"+2.1"``."""
|
"""Return a signed string representation, e.g. ``"+2.1"``."""
|
||||||
return f"{value:+.1f}"
|
return f"{value:+.1f}"
|
||||||
|
|
||||||
|
|
||||||
# Auto-profile all public functions in this module
|
|
||||||
from analysis.profile_util import auto_profile_module # noqa: E402
|
|
||||||
auto_profile_module(__name__)
|
|
||||||
|
|||||||
@@ -481,8 +481,3 @@ class SessionStore:
|
|||||||
types[t] = types.get(t, 0) + 1
|
types[t] = types.get(t, 0) + 1
|
||||||
parts = [f"{k}={v}" for k, v in sorted(types.items())]
|
parts = [f"{k}={v}" for k, v in sorted(types.items())]
|
||||||
return f"<SessionStore {'; '.join(parts)}>"
|
return f"<SessionStore {'; '.join(parts)}>"
|
||||||
|
|
||||||
|
|
||||||
# Auto-profile all public functions in this module
|
|
||||||
from analysis.profile_util import auto_profile_module # noqa: E402
|
|
||||||
auto_profile_module(__name__)
|
|
||||||
|
|||||||
@@ -60,7 +60,3 @@ from .data_mgmt import (
|
|||||||
_handle_clone_dataset,
|
_handle_clone_dataset,
|
||||||
)
|
)
|
||||||
from .distance_matrix import _handle_compute_distance_matrix
|
from .distance_matrix import _handle_compute_distance_matrix
|
||||||
|
|
||||||
# Auto-profile all public functions in this module
|
|
||||||
from analysis.profile_util import auto_profile_module # noqa: E402
|
|
||||||
auto_profile_module(__name__)
|
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ Provides:
|
|||||||
- _get_numeric_columns: extract numeric column names from schema
|
- _get_numeric_columns: extract numeric column names from schema
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
from ..session_store import SessionStore
|
from ..session_store import SessionStore
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -116,6 +119,7 @@ def _count_rows(lf: pl.LazyFrame) -> int:
|
|||||||
try:
|
try:
|
||||||
return lf.select(pl.len()).collect(streaming=True).item()
|
return lf.select(pl.len()).collect(streaming=True).item()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("_count_rows failed: {}".format(traceback.format_exc()))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+23
-14
@@ -1,9 +1,13 @@
|
|||||||
"""Read-only analysis handlers — patterns, temporal, FFT, TLS health, geo, entity detail."""
|
"""Read-only analysis handlers — patterns, temporal, FFT, TLS health, geo, entity detail."""
|
||||||
|
import traceback
|
||||||
|
from analysis.constants import LOW_MEMORY_THRESHOLD_ROWS
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
from ..session_store import SessionStore
|
from ..session_store import SessionStore
|
||||||
from ._helpers import _resolve_dataset, _count_rows, NUMERIC_TYPE_NAMES
|
from ._helpers import _resolve_dataset, _count_rows, NUMERIC_TYPE_NAMES
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_analyze_patterns(dataset_id: str, top_n: int = 10) -> dict:
|
async def _handle_analyze_patterns(dataset_id: str, top_n: int = 10) -> dict:
|
||||||
@@ -14,9 +18,9 @@ async def _handle_analyze_patterns(dataset_id: str, top_n: int = 10) -> dict:
|
|||||||
lf = entry['lazyframe']
|
lf = entry['lazyframe']
|
||||||
schema = entry.get('schema', {})
|
schema = entry.get('schema', {})
|
||||||
n = _count_rows(lf)
|
n = _count_rows(lf)
|
||||||
df = lf.head(100000).collect(streaming=True)
|
df = lf.head(LOW_MEMORY_THRESHOLD_ROWS).collect(streaming=True)
|
||||||
|
|
||||||
result = {'total_rows': n, 'sampled': min(n, 100000)}
|
result = {'total_rows': n, 'sampled': min(n, LOW_MEMORY_THRESHOLD_ROWS)}
|
||||||
|
|
||||||
for col_name, label in [(':ips', 'src_ips'), (':ipd', 'dst_ips'),
|
for col_name, label in [(':ips', 'src_ips'), (':ipd', 'dst_ips'),
|
||||||
('snam', 'snis'), ('cnam', 'cert_names')]:
|
('snam', 'snis'), ('cnam', 'cert_names')]:
|
||||||
@@ -29,6 +33,7 @@ async def _handle_analyze_patterns(dataset_id: str, top_n: int = 10) -> dict:
|
|||||||
'unique': int(df[col_name].n_unique()),
|
'unique': int(df[col_name].n_unique()),
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("_handle_analyze_patterns failed: {}".format(traceback.format_exc()))
|
||||||
pass
|
pass
|
||||||
|
|
||||||
for port_col in [':prs', ':prd']:
|
for port_col in [':prs', ':prd']:
|
||||||
@@ -41,6 +46,7 @@ async def _handle_analyze_patterns(dataset_id: str, top_n: int = 10) -> dict:
|
|||||||
'unique': int(df[port_col].n_unique()),
|
'unique': int(df[port_col].n_unique()),
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
pass
|
pass
|
||||||
|
|
||||||
for tls_col in ['0ver']:
|
for tls_col in ['0ver']:
|
||||||
@@ -50,6 +56,7 @@ async def _handle_analyze_patterns(dataset_id: str, top_n: int = 10) -> dict:
|
|||||||
result['tls_versions'] = [{'version': str(r[tls_col]), 'count': int(r['count'])}
|
result['tls_versions'] = [{'version': str(r[tls_col]), 'count': int(r['count'])}
|
||||||
for r in vc.iter_rows(named=True)]
|
for r in vc.iter_rows(named=True)]
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -116,6 +123,7 @@ async def _handle_analyze_fft(
|
|||||||
ts_parsed = ts_series.str.strptime(pl.Datetime, format=None, strict=False)
|
ts_parsed = ts_series.str.strptime(pl.Datetime, format=None, strict=False)
|
||||||
ts_numeric = ts_parsed.cast(pl.Int64) / 1_000_000_000.0
|
ts_numeric = ts_parsed.cast(pl.Int64) / 1_000_000_000.0
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
try:
|
try:
|
||||||
ts_numeric = ts_series.cast(pl.Float64)
|
ts_numeric = ts_series.cast(pl.Float64)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -202,9 +210,9 @@ async def _handle_analyze_tls_health(dataset_id: str) -> dict:
|
|||||||
lf = entry['lazyframe']
|
lf = entry['lazyframe']
|
||||||
schema = entry.get('schema', {})
|
schema = entry.get('schema', {})
|
||||||
n = _count_rows(lf)
|
n = _count_rows(lf)
|
||||||
df = lf.head(100000).collect(streaming=True)
|
df = lf.head(LOW_MEMORY_THRESHOLD_ROWS).collect(streaming=True)
|
||||||
|
|
||||||
result = {'total_rows': n, 'sampled': min(n, 100000)}
|
result = {'total_rows': n, 'sampled': min(n, LOW_MEMORY_THRESHOLD_ROWS)}
|
||||||
|
|
||||||
if '0ver' in df.columns:
|
if '0ver' in df.columns:
|
||||||
vc = df['0ver'].value_counts(sort=True)
|
vc = df['0ver'].value_counts(sort=True)
|
||||||
@@ -236,6 +244,7 @@ async def _handle_analyze_tls_health(dataset_id: str) -> dict:
|
|||||||
try:
|
try:
|
||||||
weak_count += df['cipher-suite'].str.to_lowercase().str.contains(w).sum()
|
weak_count += df['cipher-suite'].str.to_lowercase().str.contains(w).sum()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
pass
|
pass
|
||||||
result['weak_cipher_pct'] = round(float(weak_count) / max(total_ciphers, 1) * 100, 1) if total_ciphers else 0
|
result['weak_cipher_pct'] = round(float(weak_count) / max(total_ciphers, 1) * 100, 1) if total_ciphers else 0
|
||||||
|
|
||||||
@@ -254,9 +263,9 @@ async def _handle_analyze_geo_distribution(dataset_id: str) -> dict:
|
|||||||
lf = entry['lazyframe']
|
lf = entry['lazyframe']
|
||||||
schema = entry.get('schema', {})
|
schema = entry.get('schema', {})
|
||||||
n = _count_rows(lf)
|
n = _count_rows(lf)
|
||||||
df = lf.head(100000).collect(streaming=True)
|
df = lf.head(LOW_MEMORY_THRESHOLD_ROWS).collect(streaming=True)
|
||||||
|
|
||||||
result = {'total_rows': n, 'sampled': min(n, 100000)}
|
result = {'total_rows': n, 'sampled': min(n, LOW_MEMORY_THRESHOLD_ROWS)}
|
||||||
|
|
||||||
for geo_col, label in [('scnt', 'src_countries'), ('dcnt', 'dst_countries')]:
|
for geo_col, label in [('scnt', 'src_countries'), ('dcnt', 'dst_countries')]:
|
||||||
if geo_col in df.columns:
|
if geo_col in df.columns:
|
||||||
@@ -266,6 +275,7 @@ async def _handle_analyze_geo_distribution(dataset_id: str) -> dict:
|
|||||||
for r in vc.iter_rows(named=True)]
|
for r in vc.iter_rows(named=True)]
|
||||||
result[f'{label}_unique'] = int(df[geo_col].n_unique())
|
result[f'{label}_unique'] = int(df[geo_col].n_unique())
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("_handle_analyze_geo_distribution failed: {}".format(traceback.format_exc()))
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if 'scnt' in df.columns and 'dcnt' in df.columns:
|
if 'scnt' in df.columns and 'dcnt' in df.columns:
|
||||||
@@ -274,6 +284,7 @@ async def _handle_analyze_geo_distribution(dataset_id: str) -> dict:
|
|||||||
result['domestic_pct'] = round(float(same) / max(len(df), 1) * 100, 1)
|
result['domestic_pct'] = round(float(same) / max(len(df), 1) * 100, 1)
|
||||||
result['international_pct'] = round(100.0 - result['domestic_pct'], 1)
|
result['international_pct'] = round(100.0 - result['domestic_pct'], 1)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("_handle_analyze_geo_distribution failed: {}".format(traceback.format_exc()))
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -296,14 +307,11 @@ async def _handle_analyze_entity_detail(dataset_id: str, entity_value: str) -> d
|
|||||||
if not entity_col:
|
if not entity_col:
|
||||||
entity_col = list(schema.keys())[0]
|
entity_col = list(schema.keys())[0]
|
||||||
|
|
||||||
try:
|
filtered = lf.filter(pl.col(entity_col).cast(pl.Utf8).str.contains(entity_value, literal=True))
|
||||||
filtered = lf.filter(pl.col(entity_col).cast(pl.Utf8).str.contains(entity_value, literal=True))
|
n = _count_rows(filtered)
|
||||||
n = _count_rows(filtered)
|
if n == 0:
|
||||||
if n == 0:
|
return {'error': f"Entity '{entity_value}' not found in column '{entity_col}'", 'truncated': False}
|
||||||
return {'error': f"Entity '{entity_value}' not found in column '{entity_col}'", 'truncated': False}
|
df = filtered.collect(streaming=True)
|
||||||
df = filtered.collect(streaming=True)
|
|
||||||
except Exception as exc:
|
|
||||||
raise
|
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
'entity_value': entity_value,
|
'entity_value': entity_value,
|
||||||
@@ -321,6 +329,7 @@ async def _handle_analyze_entity_detail(dataset_id: str, entity_value: str) -> d
|
|||||||
'max': float(df[c].max()) if df[c].max() is not None else None,
|
'max': float(df[c].max()) if df[c].max() is not None else None,
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
"""Anomaly detection + visualization handlers."""
|
"""Anomaly detection + visualization handlers."""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from analysis.constants import MAX_DISTRIBUTION_SAMPLE, RANDOM_SEED
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
from ..session_store import SessionStore
|
from ..session_store import SessionStore
|
||||||
from ._helpers import _resolve_dataset, NUMERIC_TYPE_NAMES
|
from ._helpers import _resolve_dataset, NUMERIC_TYPE_NAMES
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_detect_anomalies(dataset_id: str, contamination: float = 0.05) -> dict:
|
async def _handle_detect_anomalies(dataset_id: str, contamination: float = 0.05) -> dict:
|
||||||
"""Run Isolation Forest on scored entity data."""
|
"""Run Isolation Forest on scored entity data."""
|
||||||
from sklearn.ensemble import IsolationForest
|
from sklearn.ensemble import IsolationForest
|
||||||
|
import traceback
|
||||||
entry, err = _resolve_dataset(dataset_id)
|
entry, err = _resolve_dataset(dataset_id)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
@@ -24,12 +28,12 @@ async def _handle_detect_anomalies(dataset_id: str, contamination: float = 0.05)
|
|||||||
mat = np.nan_to_num(mat, nan=0.0)
|
mat = np.nan_to_num(mat, nan=0.0)
|
||||||
|
|
||||||
n = len(mat)
|
n = len(mat)
|
||||||
chunk_size = min(50000, n)
|
chunk_size = min(MAX_SAMPLE_ROWS, n)
|
||||||
labels = np.zeros(n, dtype=int)
|
labels = np.zeros(n, dtype=int)
|
||||||
for start in range(0, n, chunk_size):
|
for start in range(0, n, chunk_size):
|
||||||
end = min(start + chunk_size, n)
|
end = min(start + chunk_size, n)
|
||||||
iso = IsolationForest(n_estimators=100, max_samples=min(256, chunk_size),
|
iso = IsolationForest(n_estimators=100, max_samples=min(256, chunk_size),
|
||||||
contamination=contamination, random_state=42, n_jobs=-1)
|
contamination=contamination, random_state=RANDOM_SEED, n_jobs=-1)
|
||||||
labels[start:end] = iso.fit_predict(mat[start:end])
|
labels[start:end] = iso.fit_predict(mat[start:end])
|
||||||
|
|
||||||
anomaly_count = int((labels == -1).sum())
|
anomaly_count = int((labels == -1).sum())
|
||||||
@@ -59,12 +63,13 @@ async def _handle_visualize_anomalies(dataset_id: str) -> dict:
|
|||||||
try:
|
try:
|
||||||
n_total = lf.select(pl.len()).collect(streaming=True).item()
|
n_total = lf.select(pl.len()).collect(streaming=True).item()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("_handle_visualize_anomalies failed: {}".format(traceback.format_exc()))
|
||||||
n_total = 0
|
n_total = 0
|
||||||
sample_lf = lf if n_total <= 10000 else lf.collect(streaming=True).sample(n=10000, seed=42).lazy()
|
sample_lf = lf if n_total <= MAX_DISTRIBUTION_SAMPLE else lf.collect(streaming=True).sample(n=MAX_DISTRIBUTION_SAMPLE, seed=RANDOM_SEED).lazy()
|
||||||
df = sample_lf.select(num_cols).collect(streaming=True)
|
df = sample_lf.select(num_cols).collect(streaming=True)
|
||||||
mat = StandardScaler().fit_transform(df.to_numpy())
|
mat = StandardScaler().fit_transform(df.to_numpy())
|
||||||
mat = np.nan_to_num(mat, nan=0.0)
|
mat = np.nan_to_num(mat, nan=0.0)
|
||||||
reducer = umap.UMAP(n_components=2, random_state=42,
|
reducer = umap.UMAP(n_components=2, random_state=RANDOM_SEED,
|
||||||
n_neighbors=15, min_dist=0.1, metric='euclidean')
|
n_neighbors=15, min_dist=0.1, metric='euclidean')
|
||||||
coords = reducer.fit_transform(mat)
|
coords = reducer.fit_transform(mat)
|
||||||
|
|
||||||
|
|||||||
+157
-161
@@ -6,6 +6,7 @@ Contains:
|
|||||||
- _cluster_core: shared clustering pipeline extracted from both handlers
|
- _cluster_core: shared clustering pipeline extracted from both handlers
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
from analysis.constants import LOW_MEMORY_THRESHOLD_GB, LOW_MEMORY_THRESHOLD_ROWS, MAX_CLUSTER_FEATURES, MAX_SAMPLE_ROWS, RANDOM_SEED
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -13,6 +14,7 @@ import polars as pl
|
|||||||
|
|
||||||
from ..session_store import SessionStore, _generate_id
|
from ..session_store import SessionStore, _generate_id
|
||||||
from ._helpers import (
|
from ._helpers import (
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
NUMERIC_DTYPES,
|
NUMERIC_DTYPES,
|
||||||
_build_filter_expr,
|
_build_filter_expr,
|
||||||
_resolve_dataset,
|
_resolve_dataset,
|
||||||
@@ -27,7 +29,7 @@ def _cluster_core(
|
|||||||
feature_cols: list[str],
|
feature_cols: list[str],
|
||||||
algorithm: str,
|
algorithm: str,
|
||||||
params: dict,
|
params: dict,
|
||||||
random_state: int = 42,
|
random_state: int = RANDOM_SEED,
|
||||||
):
|
):
|
||||||
"""Shared clustering pipeline: scale, filter, reduce, fit, score.
|
"""Shared clustering pipeline: scale, filter, reduce, fit, score.
|
||||||
|
|
||||||
@@ -48,20 +50,20 @@ def _cluster_core(
|
|||||||
try:
|
try:
|
||||||
import psutil
|
import psutil
|
||||||
mem = psutil.virtual_memory()
|
mem = psutil.virtual_memory()
|
||||||
low_memory = mem.available < 2 * 1024 ** 3 # 2 GB
|
low_memory = mem.available < LOW_MEMORY_THRESHOLD_GB * 1024 ** 3 # 2 GB
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if low_memory and len(data) > 100000:
|
if low_memory and len(data) > LOW_MEMORY_THRESHOLD_ROWS:
|
||||||
rng = np.random.default_rng(42)
|
rng = np.random.default_rng(RANDOM_SEED)
|
||||||
idx = rng.choice(len(data), 50000, replace=False)
|
idx = rng.choice(len(data), MAX_SAMPLE_ROWS, replace=False)
|
||||||
data = data[idx]
|
data = data[idx]
|
||||||
|
|
||||||
# ── Downsampling to 50K max ────────────────────────────────────────
|
# ── Downsampling to 50K max ────────────────────────────────────────
|
||||||
_ds_idx = None
|
_ds_idx = None
|
||||||
if len(data) > 50000:
|
if len(data) > MAX_SAMPLE_ROWS:
|
||||||
rng = np.random.default_rng(42)
|
rng = np.random.default_rng(RANDOM_SEED)
|
||||||
_ds_idx = rng.choice(len(data), 50000, replace=False)
|
_ds_idx = rng.choice(len(data), MAX_SAMPLE_ROWS, replace=False)
|
||||||
data = data[_ds_idx]
|
data = data[_ds_idx]
|
||||||
|
|
||||||
# ── Variance filtering ─────────────────────────────────────────────
|
# ── Variance filtering ─────────────────────────────────────────────
|
||||||
@@ -98,15 +100,15 @@ def _cluster_core(
|
|||||||
|
|
||||||
# ── SVD dimensionality reduction (if >50 features) ─────────────────
|
# ── SVD dimensionality reduction (if >50 features) ─────────────────
|
||||||
n_features = data_scaled.shape[1]
|
n_features = data_scaled.shape[1]
|
||||||
if n_features > 50:
|
if n_features > MAX_CLUSTER_FEATURES:
|
||||||
from sklearn.decomposition import TruncatedSVD
|
from sklearn.decomposition import TruncatedSVD
|
||||||
svd = TruncatedSVD(n_components=50, random_state=random_state)
|
svd = TruncatedSVD(n_components=MAX_CLUSTER_FEATURES, random_state=random_state)
|
||||||
data_scaled = svd.fit_transform(data_scaled)
|
data_scaled = svd.fit_transform(data_scaled)
|
||||||
|
|
||||||
# ── Hyperparameter auto-tuning ─────────────────────────────────────
|
# ── Hyperparameter auto-tuning ─────────────────────────────────────
|
||||||
n = len(data_scaled)
|
n = len(data_scaled)
|
||||||
if algorithm == 'hdbscan':
|
if algorithm == 'hdbscan':
|
||||||
min_cluster = p.get('min_cluster_size', max(3, min(50, n // 20)))
|
min_cluster = p.get('min_cluster_size', max(3, min(MAX_CLUSTER_FEATURES, n // 20)))
|
||||||
p['min_cluster_size'] = min_cluster
|
p['min_cluster_size'] = min_cluster
|
||||||
p['min_samples'] = p.get('min_samples', min_cluster)
|
p['min_samples'] = p.get('min_samples', min_cluster)
|
||||||
p['cluster_selection_epsilon'] = p.get('cluster_selection_epsilon', 0.0)
|
p['cluster_selection_epsilon'] = p.get('cluster_selection_epsilon', 0.0)
|
||||||
@@ -159,18 +161,22 @@ def _cluster_core(
|
|||||||
score = silhouette_score(data_scaled, labels_array, random_state=random_state)
|
score = silhouette_score(data_scaled, labels_array, random_state=random_state)
|
||||||
quality['silhouette_score'] = round(float(score), 4)
|
quality['silhouette_score'] = round(float(score), 4)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
quality['silhouette_score'] = None
|
quality['silhouette_score'] = None
|
||||||
try:
|
try:
|
||||||
from sklearn.metrics import davies_bouldin_score
|
from sklearn.metrics import davies_bouldin_score
|
||||||
dbs = davies_bouldin_score(data_scaled, labels_array)
|
dbs = davies_bouldin_score(data_scaled, labels_array)
|
||||||
quality['davies_bouldin_score'] = round(float(dbs), 4)
|
quality['davies_bouldin_score'] = round(float(dbs), 4)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
quality['davies_bouldin_score'] = None
|
quality['davies_bouldin_score'] = None
|
||||||
try:
|
try:
|
||||||
from sklearn.metrics import calinski_harabasz_score
|
from sklearn.metrics import calinski_harabasz_score
|
||||||
|
import traceback
|
||||||
chs = calinski_harabasz_score(data_scaled, labels_array)
|
chs = calinski_harabasz_score(data_scaled, labels_array)
|
||||||
quality['calinski_harabasz_score'] = round(float(chs), 4)
|
quality['calinski_harabasz_score'] = round(float(chs), 4)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
quality['calinski_harabasz_score'] = None
|
quality['calinski_harabasz_score'] = None
|
||||||
|
|
||||||
# Cluster sizes
|
# Cluster sizes
|
||||||
@@ -191,7 +197,7 @@ async def _handle_run_clustering(
|
|||||||
cluster_columns: list[str],
|
cluster_columns: list[str],
|
||||||
algorithm: str = 'agglomerative',
|
algorithm: str = 'agglomerative',
|
||||||
params: Optional[dict] = None,
|
params: Optional[dict] = None,
|
||||||
random_state: int = 42,
|
random_state: int = RANDOM_SEED,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Run HDBSCAN, KMeans, or Agglomerative clustering on selected columns."""
|
"""Run HDBSCAN, KMeans, or Agglomerative clustering on selected columns."""
|
||||||
entry, err = _resolve_dataset(dataset_id)
|
entry, err = _resolve_dataset(dataset_id)
|
||||||
@@ -201,91 +207,89 @@ async def _handle_run_clustering(
|
|||||||
lf: pl.LazyFrame = entry['lazyframe']
|
lf: pl.LazyFrame = entry['lazyframe']
|
||||||
p = params or {}
|
p = params or {}
|
||||||
|
|
||||||
try:
|
# Collect cluster features - numeric only
|
||||||
# Collect cluster features - numeric only
|
available = lf.collect_schema()
|
||||||
available = lf.collect_schema()
|
available_names = available.names()
|
||||||
available_names = available.names()
|
available_dtypes = available.dtypes()
|
||||||
available_dtypes = available.dtypes()
|
feature_cols = [
|
||||||
|
c for c in cluster_columns
|
||||||
|
if c in available_names
|
||||||
|
and available_dtypes[available_names.index(c)] in NUMERIC_DTYPES
|
||||||
|
]
|
||||||
|
if not feature_cols:
|
||||||
|
# Fall back: auto-select all numeric columns (exclude internal ones)
|
||||||
feature_cols = [
|
feature_cols = [
|
||||||
c for c in cluster_columns
|
available_names[i] for i, dt in enumerate(available_dtypes)
|
||||||
if c in available_names
|
if dt in NUMERIC_DTYPES and not available_names[i].startswith('_')
|
||||||
and available_dtypes[available_names.index(c)] in NUMERIC_DTYPES
|
][:10]
|
||||||
]
|
if not feature_cols:
|
||||||
|
# Fallback: try coercing Utf8 columns to Float64
|
||||||
|
utf8_cols = [
|
||||||
|
available_names[i] for i, dt in enumerate(available_dtypes)
|
||||||
|
if dt == pl.Utf8 and not available_names[i].startswith('_')
|
||||||
|
][:10]
|
||||||
|
if utf8_cols:
|
||||||
|
for col in utf8_cols:
|
||||||
|
try:
|
||||||
|
casted = lf.select(
|
||||||
|
pl.col(col).cast(pl.Float64, strict=False).alias(col)
|
||||||
|
).collect(streaming=True)
|
||||||
|
null_ratio = casted[col].is_null().sum() / max(len(casted), 1)
|
||||||
|
if null_ratio < 0.9:
|
||||||
|
lf = lf.with_columns(
|
||||||
|
pl.col(col).cast(pl.Float64, strict=False)
|
||||||
|
)
|
||||||
|
feature_cols.append(col)
|
||||||
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
|
pass
|
||||||
if not feature_cols:
|
if not feature_cols:
|
||||||
# Fall back: auto-select all numeric columns (exclude internal ones)
|
all_numeric = [f"{available_names[i]}" for i, dt in enumerate(available_dtypes)
|
||||||
feature_cols = [
|
if dt in NUMERIC_DTYPES]
|
||||||
available_names[i] for i, dt in enumerate(available_dtypes)
|
|
||||||
if dt in NUMERIC_DTYPES and not available_names[i].startswith('_')
|
|
||||||
][:10]
|
|
||||||
if not feature_cols:
|
|
||||||
# Fallback: try coercing Utf8 columns to Float64
|
|
||||||
utf8_cols = [
|
|
||||||
available_names[i] for i, dt in enumerate(available_dtypes)
|
|
||||||
if dt == pl.Utf8 and not available_names[i].startswith('_')
|
|
||||||
][:10]
|
|
||||||
if utf8_cols:
|
|
||||||
for col in utf8_cols:
|
|
||||||
try:
|
|
||||||
casted = lf.select(
|
|
||||||
pl.col(col).cast(pl.Float64, strict=False).alias(col)
|
|
||||||
).collect(streaming=True)
|
|
||||||
null_ratio = casted[col].is_null().sum() / max(len(casted), 1)
|
|
||||||
if null_ratio < 0.9:
|
|
||||||
lf = lf.with_columns(
|
|
||||||
pl.col(col).cast(pl.Float64, strict=False)
|
|
||||||
)
|
|
||||||
feature_cols.append(col)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not feature_cols:
|
|
||||||
all_numeric = [f"{available_names[i]}" for i, dt in enumerate(available_dtypes)
|
|
||||||
if dt in NUMERIC_DTYPES]
|
|
||||||
return {
|
|
||||||
'error': f'No numeric cluster columns found in dataset. Available: {all_numeric}',
|
|
||||||
'truncated': False
|
|
||||||
}
|
|
||||||
|
|
||||||
# Downsample before collect
|
|
||||||
row_count = _count_rows(lf)
|
|
||||||
if row_count > 100000:
|
|
||||||
lf = lf.collect(streaming=True).sample(n=100000, seed=42).lazy()
|
|
||||||
|
|
||||||
df_features = lf.select(feature_cols).collect(streaming=True)
|
|
||||||
|
|
||||||
# Drop all-NaN columns
|
|
||||||
for col in df_features.columns:
|
|
||||||
if df_features[col].is_null().all():
|
|
||||||
df_features = df_features.drop(col)
|
|
||||||
feature_cols = [c for c in feature_cols if c != col]
|
|
||||||
|
|
||||||
if df_features.width == 0:
|
|
||||||
return {'error': 'No valid feature columns after dropping all-NaN columns',
|
|
||||||
'truncated': False}
|
|
||||||
|
|
||||||
data = df_features.to_numpy()
|
|
||||||
|
|
||||||
# Fill remaining NaN with column mean
|
|
||||||
if data.dtype.kind == 'f':
|
|
||||||
col_mean = np.nanmean(data, axis=0)
|
|
||||||
col_mean = np.nan_to_num(col_mean, nan=0.0)
|
|
||||||
data = np.where(np.isnan(data), col_mean, data)
|
|
||||||
|
|
||||||
# Zero-sample guard
|
|
||||||
if len(data) < 3:
|
|
||||||
return {
|
return {
|
||||||
'cluster_result_id': _generate_id('clust'),
|
'error': f'No numeric cluster columns found in dataset. Available: {all_numeric}',
|
||||||
'n_clusters': 0,
|
'truncated': False
|
||||||
'n_noise': 0,
|
|
||||||
'quality_metrics': {'note': 'too few samples'},
|
|
||||||
'truncated': False,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
labels_array, n_found, noise_count, quality, _ds_idx = _cluster_core(
|
# Downsample before collect
|
||||||
data, feature_cols, algorithm, p, random_state,
|
row_count = _count_rows(lf)
|
||||||
)
|
if row_count > LOW_MEMORY_THRESHOLD_ROWS:
|
||||||
|
lf = lf.collect(streaming=True).sample(n=LOW_MEMORY_THRESHOLD_ROWS, seed=RANDOM_SEED).lazy()
|
||||||
|
|
||||||
|
df_features = lf.select(feature_cols).collect(streaming=True)
|
||||||
|
|
||||||
|
# Drop all-NaN columns
|
||||||
|
for col in df_features.columns:
|
||||||
|
if df_features[col].is_null().all():
|
||||||
|
df_features = df_features.drop(col)
|
||||||
|
feature_cols = [c for c in feature_cols if c != col]
|
||||||
|
|
||||||
|
if df_features.width == 0:
|
||||||
|
return {'error': 'No valid feature columns after dropping all-NaN columns',
|
||||||
|
'truncated': False}
|
||||||
|
|
||||||
|
data = df_features.to_numpy()
|
||||||
|
|
||||||
|
# Fill remaining NaN with column mean
|
||||||
|
if data.dtype.kind == 'f':
|
||||||
|
col_mean = np.nanmean(data, axis=0)
|
||||||
|
col_mean = np.nan_to_num(col_mean, nan=0.0)
|
||||||
|
data = np.where(np.isnan(data), col_mean, data)
|
||||||
|
|
||||||
|
# Zero-sample guard
|
||||||
|
if len(data) < 3:
|
||||||
|
return {
|
||||||
|
'cluster_result_id': _generate_id('clust'),
|
||||||
|
'n_clusters': 0,
|
||||||
|
'n_noise': 0,
|
||||||
|
'quality_metrics': {'note': 'too few samples'},
|
||||||
|
'truncated': False,
|
||||||
|
}
|
||||||
|
|
||||||
|
labels_array, n_found, noise_count, quality, _ds_idx = _cluster_core(
|
||||||
|
data, feature_cols, algorithm, p, random_state,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception:
|
|
||||||
raise
|
|
||||||
|
|
||||||
result_id = _generate_id('clust')
|
result_id = _generate_id('clust')
|
||||||
store = SessionStore()
|
store = SessionStore()
|
||||||
@@ -332,50 +336,45 @@ async def _handle_filter_and_cluster(
|
|||||||
full_schema = entry.get('schema', {})
|
full_schema = entry.get('schema', {})
|
||||||
|
|
||||||
# ── Step 1: Apply filters ─────────────────────────────────────────────
|
# ── Step 1: Apply filters ─────────────────────────────────────────────
|
||||||
try:
|
exprs = [_build_filter_expr(
|
||||||
exprs = [_build_filter_expr(
|
f['column'], f.get('op', 'eq'), f.get('value'),
|
||||||
f['column'], f.get('op', 'eq'), f.get('value'),
|
) for f in filters]
|
||||||
) for f in filters]
|
|
||||||
|
|
||||||
if exprs:
|
if exprs:
|
||||||
if logic == 'and':
|
if logic == 'and':
|
||||||
combined = exprs[0]
|
combined = exprs[0]
|
||||||
for e in exprs[1:]:
|
for e in exprs[1:]:
|
||||||
combined = combined & e
|
combined = combined & e
|
||||||
else:
|
|
||||||
combined = exprs[0]
|
|
||||||
for e in exprs[1:]:
|
|
||||||
combined = combined | e
|
|
||||||
filtered_lf = lf.filter(combined)
|
|
||||||
else:
|
else:
|
||||||
filtered_lf = lf
|
combined = exprs[0]
|
||||||
|
for e in exprs[1:]:
|
||||||
try:
|
combined = combined | e
|
||||||
row_count = _count_rows(filtered_lf)
|
filtered_lf = lf.filter(combined)
|
||||||
except Exception:
|
else:
|
||||||
row_count = None
|
filtered_lf = lf
|
||||||
|
|
||||||
|
try:
|
||||||
|
row_count = _count_rows(filtered_lf)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
|
row_count = None
|
||||||
|
|
||||||
|
|
||||||
# ── Step 2: Auto-detect numeric feature columns ───────────────────────
|
# ── Step 2: Auto-detect numeric feature columns ───────────────────────
|
||||||
try:
|
available = filtered_lf.collect_schema()
|
||||||
available = filtered_lf.collect_schema()
|
available_names = available.names()
|
||||||
available_names = available.names()
|
available_dtypes = available.dtypes()
|
||||||
available_dtypes = available.dtypes()
|
feature_cols = [
|
||||||
feature_cols = [
|
available_names[i] for i, dt in enumerate(available_dtypes)
|
||||||
available_names[i] for i, dt in enumerate(available_dtypes)
|
if dt in NUMERIC_DTYPES and not available_names[i].startswith('_')
|
||||||
if dt in NUMERIC_DTYPES and not available_names[i].startswith('_')
|
][:10]
|
||||||
][:10]
|
|
||||||
|
|
||||||
if not feature_cols:
|
if not feature_cols:
|
||||||
return {
|
return {
|
||||||
'error': 'No numeric columns found for clustering after filtering',
|
'error': 'No numeric columns found for clustering after filtering',
|
||||||
'filtered_dataset_id': None,
|
'filtered_dataset_id': None,
|
||||||
'truncated': False,
|
'truncated': False,
|
||||||
}
|
}
|
||||||
except Exception:
|
|
||||||
raise
|
|
||||||
|
|
||||||
# ── Step 3: Store filtered dataset ────────────────────────────────────
|
# ── Step 3: Store filtered dataset ────────────────────────────────────
|
||||||
filtered_dataset_id = _generate_id('flt')
|
filtered_dataset_id = _generate_id('flt')
|
||||||
@@ -395,46 +394,43 @@ async def _handle_filter_and_cluster(
|
|||||||
|
|
||||||
# ── Step 4: Run clustering ────────────────────────────────────────────
|
# ── Step 4: Run clustering ────────────────────────────────────────────
|
||||||
p = params or {}
|
p = params or {}
|
||||||
try:
|
total_count = _count_rows(filtered_lf)
|
||||||
total_count = _count_rows(filtered_lf)
|
clamp_lf = filtered_lf
|
||||||
clamp_lf = filtered_lf
|
if total_count > LOW_MEMORY_THRESHOLD_ROWS:
|
||||||
if total_count > 100000:
|
clamp_lf = filtered_lf.collect(streaming=True).sample(n=LOW_MEMORY_THRESHOLD_ROWS, seed=RANDOM_SEED).lazy()
|
||||||
clamp_lf = filtered_lf.collect(streaming=True).sample(n=100000, seed=42).lazy()
|
|
||||||
|
|
||||||
df_features = clamp_lf.select(feature_cols).collect(streaming=True)
|
df_features = clamp_lf.select(feature_cols).collect(streaming=True)
|
||||||
|
|
||||||
for col in df_features.columns:
|
for col in df_features.columns:
|
||||||
if df_features[col].is_null().all():
|
if df_features[col].is_null().all():
|
||||||
df_features = df_features.drop(col)
|
df_features = df_features.drop(col)
|
||||||
feature_cols = [c for c in feature_cols if c != col]
|
feature_cols = [c for c in feature_cols if c != col]
|
||||||
|
|
||||||
if df_features.width == 0:
|
if df_features.width == 0:
|
||||||
return {'error': 'No valid feature columns after dropping all-NaN columns',
|
return {'error': 'No valid feature columns after dropping all-NaN columns',
|
||||||
'filtered_dataset_id': filtered_dataset_id, 'truncated': False}
|
'filtered_dataset_id': filtered_dataset_id, 'truncated': False}
|
||||||
|
|
||||||
data = df_features.to_numpy()
|
data = df_features.to_numpy()
|
||||||
|
|
||||||
if data.dtype.kind == 'f':
|
if data.dtype.kind == 'f':
|
||||||
col_mean = np.nanmean(data, axis=0)
|
col_mean = np.nanmean(data, axis=0)
|
||||||
col_mean = np.nan_to_num(col_mean, nan=0.0)
|
col_mean = np.nan_to_num(col_mean, nan=0.0)
|
||||||
data = np.where(np.isnan(data), col_mean, data)
|
data = np.where(np.isnan(data), col_mean, data)
|
||||||
|
|
||||||
if len(data) < 3:
|
if len(data) < 3:
|
||||||
return {
|
return {
|
||||||
'cluster_result_id': _generate_id('clust'),
|
'cluster_result_id': _generate_id('clust'),
|
||||||
'n_clusters': 0,
|
'n_clusters': 0,
|
||||||
'n_noise': 0,
|
'n_noise': 0,
|
||||||
'quality_metrics': {'note': 'too few samples'},
|
'quality_metrics': {'note': 'too few samples'},
|
||||||
'filtered_dataset_id': filtered_dataset_id,
|
'filtered_dataset_id': filtered_dataset_id,
|
||||||
'truncated': False,
|
'truncated': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
labels_array, n_found, noise_count, quality, _ds_idx = _cluster_core(
|
labels_array, n_found, noise_count, quality, _ds_idx = _cluster_core(
|
||||||
data, feature_cols, algorithm, p, 42,
|
data, feature_cols, algorithm, p, RANDOM_SEED,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception:
|
|
||||||
raise
|
|
||||||
|
|
||||||
result_id = _generate_id('clust')
|
result_id = _generate_id('clust')
|
||||||
store.store_cluster_result(
|
store.store_cluster_result(
|
||||||
@@ -444,7 +440,7 @@ async def _handle_filter_and_cluster(
|
|||||||
params={
|
params={
|
||||||
'algorithm': algorithm,
|
'algorithm': algorithm,
|
||||||
'params': p,
|
'params': p,
|
||||||
'random_state': 42,
|
'random_state': RANDOM_SEED,
|
||||||
'feature_columns': feature_cols,
|
'feature_columns': feature_cols,
|
||||||
'_ds_idx': _ds_idx.tolist() if _ds_idx is not None else None,
|
'_ds_idx': _ds_idx.tolist() if _ds_idx is not None else None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"""Diagnostic tool handlers — validate, explore, diagnose, compare, repair."""
|
"""Diagnostic tool handlers — validate, explore, diagnose, compare, repair."""
|
||||||
|
import traceback
|
||||||
|
from analysis.constants import LOW_MEMORY_THRESHOLD_ROWS, RANDOM_SEED
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
from ..session_store import SessionStore
|
from ..session_store import SessionStore
|
||||||
from ._helpers import _resolve_dataset, _count_rows, NUMERIC_TYPE_NAMES
|
from ._helpers import _resolve_dataset, _count_rows, NUMERIC_TYPE_NAMES
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_validate_data(dataset_id: str, fix_issues: bool = False) -> dict:
|
async def _handle_validate_data(dataset_id: str, fix_issues: bool = False) -> dict:
|
||||||
@@ -13,20 +17,18 @@ async def _handle_validate_data(dataset_id: str, fix_issues: bool = False) -> di
|
|||||||
lf = entry['lazyframe']
|
lf = entry['lazyframe']
|
||||||
schema = entry.get('schema', {})
|
schema = entry.get('schema', {})
|
||||||
issues = []
|
issues = []
|
||||||
try:
|
df_sample = lf.head(1000).collect(streaming=True)
|
||||||
df_sample = lf.head(1000).collect(streaming=True)
|
for col in schema:
|
||||||
for col in schema:
|
null_count = df_sample[col].is_null().sum()
|
||||||
null_count = df_sample[col].is_null().sum()
|
if null_count > 500:
|
||||||
if null_count > 500:
|
issues.append(f'{col}: {null_count/10:.0f}% null')
|
||||||
issues.append(f'{col}: {null_count/10:.0f}% null')
|
try:
|
||||||
try:
|
unique = df_sample[col].n_unique()
|
||||||
unique = df_sample[col].n_unique()
|
if unique <= 1 and null_count == 0:
|
||||||
if unique <= 1 and null_count == 0:
|
issues.append(f'{col}: constant value (no variation)')
|
||||||
issues.append(f'{col}: constant value (no variation)')
|
except Exception:
|
||||||
except Exception:
|
logger.error("_handle_validate_data failed: {}".format(traceback.format_exc()))
|
||||||
pass
|
pass
|
||||||
except Exception as exc:
|
|
||||||
raise
|
|
||||||
total_rows = _count_rows(lf)
|
total_rows = _count_rows(lf)
|
||||||
return {
|
return {
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
@@ -55,7 +57,7 @@ async def _handle_explore_distributions(dataset_id: str, columns: list[str] = No
|
|||||||
try:
|
try:
|
||||||
n = _count_rows(lf)
|
n = _count_rows(lf)
|
||||||
if n > max_sample:
|
if n > max_sample:
|
||||||
sample_lf = lf.select(columns).sample(n=max_sample, seed=42)
|
sample_lf = lf.select(columns).sample(n=max_sample, seed=RANDOM_SEED)
|
||||||
else:
|
else:
|
||||||
sample_lf = lf.select(columns)
|
sample_lf = lf.select(columns)
|
||||||
df = sample_lf.collect(streaming=True)
|
df = sample_lf.collect(streaming=True)
|
||||||
@@ -78,6 +80,7 @@ async def _handle_explore_distributions(dataset_id: str, columns: list[str] = No
|
|||||||
stats[col]['mean'] = round(float(s.mean()), 4) if s.mean() is not None else None
|
stats[col]['mean'] = round(float(s.mean()), 4) if s.mean() is not None else None
|
||||||
stats[col]['std'] = round(float(s.std()), 4) if s.std() is not None else None
|
stats[col]['std'] = round(float(s.std()), 4) if s.std() is not None else None
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
stats[col] = {'error': 'failed to compute'}
|
stats[col] = {'error': 'failed to compute'}
|
||||||
return {'columns': stats, 'total_rows': n, 'sampled': min(n, max_sample)}
|
return {'columns': stats, 'total_rows': n, 'sampled': min(n, max_sample)}
|
||||||
|
|
||||||
@@ -92,12 +95,9 @@ async def _handle_find_outliers(dataset_id: str, columns: list[str] = None,
|
|||||||
schema = entry.get('schema', {})
|
schema = entry.get('schema', {})
|
||||||
if not columns:
|
if not columns:
|
||||||
columns = [c for c in schema if schema[c].split('(')[0].strip() in NUMERIC_TYPE_NAMES]
|
columns = [c for c in schema if schema[c].split('(')[0].strip() in NUMERIC_TYPE_NAMES]
|
||||||
try:
|
n = _count_rows(lf)
|
||||||
n = _count_rows(lf)
|
sample_lf = lf if n <= 100000 else lf.collect(streaming=True).sample(n=LOW_MEMORY_THRESHOLD_ROWS, seed=RANDOM_SEED).lazy()
|
||||||
sample_lf = lf if n <= 100000 else lf.collect(streaming=True).sample(n=100000, seed=42).lazy()
|
df = sample_lf.select(columns).collect(streaming=True)
|
||||||
df = sample_lf.select(columns).collect(streaming=True)
|
|
||||||
except Exception as exc:
|
|
||||||
raise
|
|
||||||
|
|
||||||
outlier_counts = {}
|
outlier_counts = {}
|
||||||
for col in columns:
|
for col in columns:
|
||||||
@@ -111,6 +111,7 @@ async def _handle_find_outliers(dataset_id: str, columns: list[str] = None,
|
|||||||
if n_out > 0:
|
if n_out > 0:
|
||||||
outlier_counts[col] = int(n_out)
|
outlier_counts[col] = int(n_out)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("_handle_find_outliers failed: {}".format(traceback.format_exc()))
|
||||||
pass
|
pass
|
||||||
return {'outlier_counts': outlier_counts, 'total_sampled': len(df), 'threshold_iqr': threshold}
|
return {'outlier_counts': outlier_counts, 'total_sampled': len(df), 'threshold_iqr': threshold}
|
||||||
|
|
||||||
@@ -152,11 +153,12 @@ async def _handle_diagnose_clustering(dataset_id: str, cluster_result_id: str) -
|
|||||||
df_sample = lf.select(num_cols).head(5000).collect(streaming=True)
|
df_sample = lf.select(num_cols).head(5000).collect(streaming=True)
|
||||||
mat = df_sample.to_numpy()
|
mat = df_sample.to_numpy()
|
||||||
n_comp = min(mat.shape)
|
n_comp = min(mat.shape)
|
||||||
svd = TruncatedSVD(n_components=n_comp, random_state=42).fit(mat)
|
svd = TruncatedSVD(n_components=n_comp, random_state=RANDOM_SEED).fit(mat)
|
||||||
var_explained = svd.explained_variance_ratio_
|
var_explained = svd.explained_variance_ratio_
|
||||||
if var_explained[0] > 0.95:
|
if var_explained[0] > 0.95:
|
||||||
diagnosis.append(f'First SVD component explains {var_explained[0]:.0%} variance — data nearly 1D')
|
diagnosis.append(f'First SVD component explains {var_explained[0]:.0%} variance — data nearly 1D')
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -187,6 +189,7 @@ async def _handle_compare_datasets(dataset_id_a: str, dataset_id_b: str) -> dict
|
|||||||
try:
|
try:
|
||||||
n = _count_rows(lf)
|
n = _count_rows(lf)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("_get_info failed: {}".format(traceback.format_exc()))
|
||||||
n = -1
|
n = -1
|
||||||
return {'schema': set(schema.keys()), 'row_count': n, 'columns': len(schema)}
|
return {'schema': set(schema.keys()), 'row_count': n, 'columns': len(schema)}
|
||||||
|
|
||||||
@@ -210,24 +213,21 @@ async def _handle_export_debug_sample(dataset_id: str, max_rows: int = 50) -> di
|
|||||||
entry, err = _resolve_dataset(dataset_id)
|
entry, err = _resolve_dataset(dataset_id)
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
try:
|
df = entry['lazyframe'].head(max_rows).collect(streaming=True)
|
||||||
df = entry['lazyframe'].head(max_rows).collect(streaming=True)
|
rows = df.to_dict(as_series=False)
|
||||||
rows = df.to_dict(as_series=False)
|
result = []
|
||||||
result = []
|
for i in range(len(df)):
|
||||||
for i in range(len(df)):
|
row = {}
|
||||||
row = {}
|
for col in df.columns:
|
||||||
for col in df.columns:
|
val = rows[col][i]
|
||||||
val = rows[col][i]
|
if isinstance(val, (float, int, str, bool)):
|
||||||
if isinstance(val, (float, int, str, bool)):
|
row[col] = val
|
||||||
row[col] = val
|
elif val is None:
|
||||||
elif val is None:
|
row[col] = None
|
||||||
row[col] = None
|
else:
|
||||||
else:
|
row[col] = str(val)
|
||||||
row[col] = str(val)
|
result.append(row)
|
||||||
result.append(row)
|
return {'rows': result, 'count': len(result), 'columns': df.columns}
|
||||||
return {'rows': result, 'count': len(result), 'columns': df.columns}
|
|
||||||
except Exception as exc:
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_repair_schema(dataset_id: str) -> dict:
|
async def _handle_repair_schema(dataset_id: str) -> dict:
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""compute_distance_matrix handler — LLM-driven per-row evaluation."""
|
"""compute_distance_matrix handler — LLM-driven per-row evaluation."""
|
||||||
|
import traceback
|
||||||
|
from analysis.constants import RANDOM_SEED
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -6,6 +8,8 @@ import polars as pl
|
|||||||
|
|
||||||
from ..session_store import SessionStore
|
from ..session_store import SessionStore
|
||||||
from ._helpers import _resolve_dataset, NUMERIC_DTYPES
|
from ._helpers import _resolve_dataset, NUMERIC_DTYPES
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _indent_function_body(body: str, indent: str = ' ') -> str:
|
def _indent_function_body(body: str, indent: str = ' ') -> str:
|
||||||
@@ -43,7 +47,7 @@ async def _handle_compute_distance_matrix(
|
|||||||
n_total = len(df)
|
n_total = len(df)
|
||||||
|
|
||||||
if n_total > max_sample:
|
if n_total > max_sample:
|
||||||
df_sampled = df.sample(n=max_sample, seed=42)
|
df_sampled = df.sample(n=max_sample, seed=RANDOM_SEED)
|
||||||
n_evaluated = max_sample
|
n_evaluated = max_sample
|
||||||
else:
|
else:
|
||||||
df_sampled = df
|
df_sampled = df
|
||||||
@@ -103,6 +107,7 @@ async def _handle_compute_distance_matrix(
|
|||||||
scores.append(None)
|
scores.append(None)
|
||||||
errors += 1
|
errors += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
scores.append(None)
|
scores.append(None)
|
||||||
errors += 1
|
errors += 1
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""Entity profile building + adaptive scoring handlers."""
|
"""Entity profile building + adaptive scoring handlers."""
|
||||||
|
import traceback
|
||||||
|
from analysis.constants import MAX_DISTRIBUTION_SAMPLE, RANDOM_SEED
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -8,6 +10,7 @@ import polars as pl
|
|||||||
from ..session_store import SessionStore, _generate_id
|
from ..session_store import SessionStore, _generate_id
|
||||||
from ..profile_util import profile
|
from ..profile_util import profile
|
||||||
from ._helpers import _resolve_dataset, _count_rows, NUMERIC_DTYPES
|
from ._helpers import _resolve_dataset, _count_rows, NUMERIC_DTYPES
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -113,11 +116,8 @@ async def _handle_build_entity_profiles(
|
|||||||
agg_exprs.append(pl.col(c).n_unique().alias(alias_name))
|
agg_exprs.append(pl.col(c).n_unique().alias(alias_name))
|
||||||
|
|
||||||
# ── Execute grouping ───────────────────────────────────────────────
|
# ── Execute grouping ───────────────────────────────────────────────
|
||||||
try:
|
entity_lf = lf.group_by(group_cols).agg(agg_exprs)
|
||||||
entity_lf = lf.group_by(group_cols).agg(agg_exprs)
|
n_entities = _count_rows(entity_lf)
|
||||||
n_entities = _count_rows(entity_lf)
|
|
||||||
except Exception:
|
|
||||||
raise
|
|
||||||
|
|
||||||
# ── Store result ───────────────────────────────────────────────────
|
# ── Store result ───────────────────────────────────────────────────
|
||||||
new_schema = {name: str(dtype) for name, dtype in
|
new_schema = {name: str(dtype) for name, dtype in
|
||||||
@@ -160,7 +160,7 @@ def _add_adaptive_scores(lf: pl.LazyFrame, feature_names: list[str]) -> pl.LazyF
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
n_rows = _count_rows(lf)
|
n_rows = _count_rows(lf)
|
||||||
sample_lf = lf if n_rows <= 10000 else lf.sample(n=10000, seed=42)
|
sample_lf = lf if n_rows <= MAX_DISTRIBUTION_SAMPLE else lf.sample(n=MAX_DISTRIBUTION_SAMPLE, seed=RANDOM_SEED)
|
||||||
df_sample = sample_lf.select(avail).collect(streaming=True)
|
df_sample = sample_lf.select(avail).collect(streaming=True)
|
||||||
mat = df_sample.to_numpy()
|
mat = df_sample.to_numpy()
|
||||||
mat = np.nan_to_num(mat, nan=0.0)
|
mat = np.nan_to_num(mat, nan=0.0)
|
||||||
@@ -169,7 +169,7 @@ def _add_adaptive_scores(lf: pl.LazyFrame, feature_names: list[str]) -> pl.LazyF
|
|||||||
n_comp = min(1, min(mat.shape) - 1)
|
n_comp = min(1, min(mat.shape) - 1)
|
||||||
if n_comp < 1 or mat.shape[1] < 2:
|
if n_comp < 1 or mat.shape[1] < 2:
|
||||||
return lf
|
return lf
|
||||||
svd = TruncatedSVD(n_components=1, random_state=42).fit(mat)
|
svd = TruncatedSVD(n_components=1, random_state=RANDOM_SEED).fit(mat)
|
||||||
raw_weights = np.abs(svd.components_[0])
|
raw_weights = np.abs(svd.components_[0])
|
||||||
weights = dict(zip(avail, raw_weights / raw_weights.sum()))
|
weights = dict(zip(avail, raw_weights / raw_weights.sum()))
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ def _add_adaptive_scores(lf: pl.LazyFrame, feature_names: list[str]) -> pl.LazyF
|
|||||||
'[SCORE] adaptive weights from SVD: %s | thresholds: p50=%.3f upper=%.3f extreme=%.3f | sample=%d/%d',
|
'[SCORE] adaptive weights from SVD: %s | thresholds: p50=%.3f upper=%.3f extreme=%.3f | sample=%d/%d',
|
||||||
{c: f'{w:.3f}' for c, w in sorted(weights.items(), key=lambda x: -x[1])},
|
{c: f'{w:.3f}' for c, w in sorted(weights.items(), key=lambda x: -x[1])},
|
||||||
float(q50), upper_fence, extreme_fence,
|
float(q50), upper_fence, extreme_fence,
|
||||||
min(n_rows, 10000), n_rows,
|
min(n_rows, MAX_DISTRIBUTION_SAMPLE), n_rows,
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -248,6 +248,7 @@ async def _handle_compute_scores(dataset_id: str) -> dict:
|
|||||||
new_schema = {name: str(dtype) for name, dtype in
|
new_schema = {name: str(dtype) for name, dtype in
|
||||||
zip(updated_schema.names(), updated_schema.dtypes())}
|
zip(updated_schema.names(), updated_schema.dtypes())}
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("_handle_compute_scores failed: {}".format(traceback.format_exc()))
|
||||||
new_schema = entry.get('schema', {})
|
new_schema = entry.get('schema', {})
|
||||||
store = SessionStore()
|
store = SessionStore()
|
||||||
store.store_dataset(dataset_id, lf, schema=new_schema,
|
store.store_dataset(dataset_id, lf, schema=new_schema,
|
||||||
|
|||||||
+37
-39
@@ -1,5 +1,6 @@
|
|||||||
"""evaluate_clustering handler — compute clustering quality metrics."""
|
"""evaluate_clustering handler — compute clustering quality metrics."""
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from analysis.constants import RANDOM_SEED
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import polars as pl
|
import polars as pl
|
||||||
@@ -30,49 +31,46 @@ async def _handle_evaluate_clustering(
|
|||||||
|
|
||||||
scores: dict = {'n_clusters': cluster_entry['n_clusters']}
|
scores: dict = {'n_clusters': cluster_entry['n_clusters']}
|
||||||
|
|
||||||
try:
|
lf: pl.LazyFrame = dataset_entry['lazyframe']
|
||||||
lf: pl.LazyFrame = dataset_entry['lazyframe']
|
params = cluster_entry.get('params', {})
|
||||||
params = cluster_entry.get('params', {})
|
feature_cols = params.get('feature_columns', [])
|
||||||
feature_cols = params.get('feature_columns', [])
|
if not feature_cols and 'feature_columns' in params:
|
||||||
if not feature_cols and 'feature_columns' in params:
|
pass
|
||||||
pass
|
elif feature_cols:
|
||||||
elif feature_cols:
|
df_features = lf.select(feature_cols).collect(streaming=True)
|
||||||
df_features = lf.select(feature_cols).collect(streaming=True)
|
data = df_features.to_numpy()
|
||||||
data = df_features.to_numpy()
|
|
||||||
|
|
||||||
# Align labels with data (handle missing rows)
|
# Align labels with data (handle missing rows)
|
||||||
valid_mask = ~np.isnan(data).any(axis=1) if data.dtype.kind == 'f' else np.ones(len(data), dtype=bool)
|
valid_mask = ~np.isnan(data).any(axis=1) if data.dtype.kind == 'f' else np.ones(len(data), dtype=bool)
|
||||||
data_clean = data[valid_mask]
|
data_clean = data[valid_mask]
|
||||||
labels_clean = np.array(labels)[:len(data_clean)]
|
labels_clean = np.array(labels)[:len(data_clean)]
|
||||||
|
|
||||||
unique_labels = set(labels_clean)
|
unique_labels = set(labels_clean)
|
||||||
n_clusters_actual = len(unique_labels - {-1})
|
n_clusters_actual = len(unique_labels - {-1})
|
||||||
|
|
||||||
for m in requested:
|
for m in requested:
|
||||||
if m == 'silhouette' and n_clusters_actual >= 2:
|
if m == 'silhouette' and n_clusters_actual >= 2:
|
||||||
from sklearn.metrics import silhouette_score
|
from sklearn.metrics import silhouette_score
|
||||||
scores['silhouette'] = round(float(
|
scores['silhouette'] = round(float(
|
||||||
silhouette_score(data_clean, labels_clean, random_state=42)), 4)
|
silhouette_score(data_clean, labels_clean, random_state=RANDOM_SEED)), 4)
|
||||||
elif m == 'davies_bouldin' and n_clusters_actual >= 2:
|
elif m == 'davies_bouldin' and n_clusters_actual >= 2:
|
||||||
from sklearn.metrics import davies_bouldin_score
|
from sklearn.metrics import davies_bouldin_score
|
||||||
scores['davies_bouldin'] = round(float(
|
scores['davies_bouldin'] = round(float(
|
||||||
davies_bouldin_score(data_clean, labels_clean)), 4)
|
davies_bouldin_score(data_clean, labels_clean)), 4)
|
||||||
elif m == 'calinski_harabasz' and n_clusters_actual >= 2:
|
elif m == 'calinski_harabasz' and n_clusters_actual >= 2:
|
||||||
from sklearn.metrics import calinski_harabasz_score
|
from sklearn.metrics import calinski_harabasz_score
|
||||||
scores['calinski_harabasz'] = round(float(
|
scores['calinski_harabasz'] = round(float(
|
||||||
calinski_harabasz_score(data_clean, labels_clean)), 4)
|
calinski_harabasz_score(data_clean, labels_clean)), 4)
|
||||||
elif m == 'noise_ratio':
|
elif m == 'noise_ratio':
|
||||||
noise = int((labels_clean == -1).sum())
|
noise = int((labels_clean == -1).sum())
|
||||||
scores['noise_ratio'] = round(noise / len(labels_clean), 4)
|
scores['noise_ratio'] = round(noise / len(labels_clean), 4)
|
||||||
elif m == 'cluster_sizes':
|
elif m == 'cluster_sizes':
|
||||||
sizes = {}
|
sizes = {}
|
||||||
for label in unique_labels:
|
for label in unique_labels:
|
||||||
sizes[int(label)] = int((labels_clean == label).sum())
|
sizes[int(label)] = int((labels_clean == label).sum())
|
||||||
scores['cluster_sizes'] = sizes
|
scores['cluster_sizes'] = sizes
|
||||||
|
|
||||||
scores['n_clusters'] = cluster_entry['n_clusters']
|
scores['n_clusters'] = cluster_entry['n_clusters']
|
||||||
|
|
||||||
except Exception:
|
|
||||||
raise
|
|
||||||
|
|
||||||
return {'scores': scores}
|
return {'scores': scores}
|
||||||
|
|||||||
+17
-20
@@ -23,30 +23,27 @@ async def _handle_export_results(
|
|||||||
dataset_entry = store.get_dataset(result_id)
|
dataset_entry = store.get_dataset(result_id)
|
||||||
if dataset_entry is not None:
|
if dataset_entry is not None:
|
||||||
lf: pl.LazyFrame = dataset_entry['lazyframe']
|
lf: pl.LazyFrame = dataset_entry['lazyframe']
|
||||||
try:
|
df = lf.collect(streaming=True)
|
||||||
df = lf.collect(streaming=True)
|
if out.suffix == '':
|
||||||
if out.suffix == '':
|
out = out / f"export_{result_id}.{format}"
|
||||||
out = out / f"export_{result_id}.{format}"
|
|
||||||
|
|
||||||
if out.exists() and not overwrite:
|
if out.exists() and not overwrite:
|
||||||
return {'error': f'Output exists and overwrite=False: {out}',
|
return {'error': f'Output exists and overwrite=False: {out}',
|
||||||
'truncated': False}
|
'truncated': False}
|
||||||
|
|
||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
if format == 'csv':
|
if format == 'csv':
|
||||||
df.write_csv(out)
|
df.write_csv(out)
|
||||||
elif format == 'parquet':
|
elif format == 'parquet':
|
||||||
df.write_parquet(out)
|
df.write_parquet(out)
|
||||||
elif format == 'json':
|
elif format == 'json':
|
||||||
df.write_json(out)
|
df.write_json(out)
|
||||||
else:
|
else:
|
||||||
return {'error': f'Unsupported format: {format}',
|
return {'error': f'Unsupported format: {format}',
|
||||||
'truncated': False}
|
'truncated': False}
|
||||||
|
|
||||||
file_paths.append(str(out.resolve()))
|
file_paths.append(str(out.resolve()))
|
||||||
except Exception:
|
|
||||||
raise
|
|
||||||
|
|
||||||
return {'file_paths': file_paths, 'format': format,
|
return {'file_paths': file_paths, 'format': format,
|
||||||
'row_count': len(df)}
|
'row_count': len(df)}
|
||||||
|
|||||||
+167
-162
@@ -1,9 +1,15 @@
|
|||||||
"""extract_features handler — per-cluster feature ranking + ORM persistence."""
|
"""extract_features handler — per-cluster feature ranking + ORM persistence."""
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
from ..session_store import SessionStore, _generate_id
|
from ..session_store import SessionStore, _generate_id
|
||||||
from ..profile_util import profile
|
from ..profile_util import profile
|
||||||
|
from analysis.constants import RANDOM_SEED, UMAP_BATCH_SIZE, UMAP_TRAIN_SAMPLE
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_extract_features(
|
async def _handle_extract_features(
|
||||||
@@ -25,180 +31,177 @@ async def _handle_extract_features(
|
|||||||
return {'error': f'Cluster result not found: {cluster_result_id}',
|
return {'error': f'Cluster result not found: {cluster_result_id}',
|
||||||
'truncated': False}
|
'truncated': False}
|
||||||
|
|
||||||
try:
|
lf: pl.LazyFrame = dataset_entry['lazyframe']
|
||||||
lf: pl.LazyFrame = dataset_entry['lazyframe']
|
labels = cluster_entry['labels']
|
||||||
labels = cluster_entry['labels']
|
|
||||||
|
|
||||||
# D5: If clustering downsampled the data, filter matching rows
|
# D5: If clustering downsampled the data, filter matching rows
|
||||||
_ds_idx = cluster_entry.get('params', {}).get('_ds_idx')
|
_ds_idx = cluster_entry.get('params', {}).get('_ds_idx')
|
||||||
if _ds_idx is not None:
|
if _ds_idx is not None:
|
||||||
df = (lf.with_row_index('__ds_idx')
|
df = (lf.with_row_index('__ds_idx')
|
||||||
.filter(pl.col('__ds_idx').is_in(_ds_idx))
|
.filter(pl.col('__ds_idx').is_in(_ds_idx))
|
||||||
.drop('__ds_idx')
|
.drop('__ds_idx')
|
||||||
.collect(streaming=True))
|
.collect(streaming=True))
|
||||||
else:
|
else:
|
||||||
df = lf.collect(streaming=True)
|
df = lf.collect(streaming=True)
|
||||||
|
|
||||||
# Truncate labels if df was filtered but labels weren't
|
# Truncate labels if df was filtered but labels weren't
|
||||||
if len(labels) != len(df):
|
if len(labels) != len(df):
|
||||||
labels = labels[:len(df)]
|
labels = labels[:len(df)]
|
||||||
|
|
||||||
# Only use numeric columns for feature extraction
|
# Only use numeric columns for feature extraction
|
||||||
numeric_cols = [c for c in df.columns
|
numeric_cols = [c for c in df.columns
|
||||||
if df[c].dtype in (pl.Float32, pl.Float64,
|
if df[c].dtype in (pl.Float32, pl.Float64,
|
||||||
pl.Int32, pl.Int64,
|
pl.Int32, pl.Int64,
|
||||||
pl.UInt32, pl.UInt64)]
|
pl.UInt32, pl.UInt64)]
|
||||||
if len(labels) != len(df):
|
if len(labels) != len(df):
|
||||||
labels = labels[:len(df)]
|
labels = labels[:len(df)]
|
||||||
|
|
||||||
|
# Global statistics per column
|
||||||
|
global_stats = {}
|
||||||
|
for col in numeric_cols:
|
||||||
|
s = df[col].drop_nulls()
|
||||||
|
if len(s) > 0:
|
||||||
|
global_stats[col] = {
|
||||||
|
'mean': float(s.mean()),
|
||||||
|
'std': float(s.std()) if s.std() is not None else 1.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
unique_labels = sorted(set(labels))
|
||||||
|
all_features = []
|
||||||
|
|
||||||
|
for label in unique_labels:
|
||||||
|
mask = np.array(labels) == label
|
||||||
|
if mask.sum() < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cluster_df = df.filter(pl.Series('__mask__', mask))
|
||||||
|
cluster_features = []
|
||||||
|
|
||||||
# Global statistics per column
|
|
||||||
global_stats = {}
|
|
||||||
for col in numeric_cols:
|
for col in numeric_cols:
|
||||||
s = df[col].drop_nulls()
|
c_series = cluster_df[col].drop_nulls()
|
||||||
if len(s) > 0:
|
gs = global_stats.get(col)
|
||||||
global_stats[col] = {
|
if gs is None or gs['std'] == 0 or len(c_series) == 0:
|
||||||
'mean': float(s.mean()),
|
|
||||||
'std': float(s.std()) if s.std() is not None else 1.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
unique_labels = sorted(set(labels))
|
|
||||||
all_features = []
|
|
||||||
|
|
||||||
for label in unique_labels:
|
|
||||||
mask = np.array(labels) == label
|
|
||||||
if mask.sum() < 2:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cluster_df = df.filter(pl.Series('__mask__', mask))
|
c_mean = float(c_series.mean())
|
||||||
cluster_features = []
|
c_std = float(c_series.std()) if c_series.std() is not None else 0.0
|
||||||
|
|
||||||
for col in numeric_cols:
|
if method == 'zscore':
|
||||||
c_series = cluster_df[col].drop_nulls()
|
score = (c_mean - gs['mean']) / max(gs['std'], 1e-10)
|
||||||
gs = global_stats.get(col)
|
else:
|
||||||
if gs is None or gs['std'] == 0 or len(c_series) == 0:
|
score = abs(c_mean - gs['mean']) / max(c_std + gs['std'], 1e-10)
|
||||||
continue
|
|
||||||
|
|
||||||
c_mean = float(c_series.mean())
|
cluster_features.append({
|
||||||
c_std = float(c_series.std()) if c_series.std() is not None else 0.0
|
'feature_name': col,
|
||||||
|
'cluster_label': int(label),
|
||||||
|
'mean': round(c_mean, 4),
|
||||||
|
'std': round(c_std, 4),
|
||||||
|
'global_mean': round(gs['mean'], 4),
|
||||||
|
'global_std': round(gs['std'], 4),
|
||||||
|
'distinguishing_score': round(float(score), 4),
|
||||||
|
'distinguishing_method': method,
|
||||||
|
})
|
||||||
|
|
||||||
if method == 'zscore':
|
cluster_features.sort(key=lambda x: abs(x['distinguishing_score']), reverse=True)
|
||||||
score = (c_mean - gs['mean']) / max(gs['std'], 1e-10)
|
all_features.extend(cluster_features[:top_k])
|
||||||
|
|
||||||
|
# ── Compute UMAP-2D embedding ──────────────────────────────────
|
||||||
|
if len(numeric_cols) >= 2 and len(df) > 2:
|
||||||
|
try:
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
import umap
|
||||||
|
MAX_UMAP_TRAIN = UMAP_TRAIN_SAMPLE
|
||||||
|
BATCH_SIZE = UMAP_BATCH_SIZE
|
||||||
|
data_matrix = df.select(numeric_cols).to_numpy()
|
||||||
|
if len(df) > MAX_UMAP_TRAIN:
|
||||||
|
idx_sample = np.random.RandomState(RANDOM_SEED).choice(
|
||||||
|
len(df), size=MAX_UMAP_TRAIN, replace=False)
|
||||||
|
mat_sample = data_matrix[idx_sample]
|
||||||
|
scaler = StandardScaler().fit(mat_sample)
|
||||||
|
mat_sample_scaled = np.nan_to_num(scaler.transform(mat_sample),
|
||||||
|
nan=0.0, posinf=0.0, neginf=0.0)
|
||||||
|
reducer = umap.UMAP(n_components=2, random_state=RANDOM_SEED,
|
||||||
|
n_neighbors=15, min_dist=0.1,
|
||||||
|
metric='euclidean')
|
||||||
|
reducer.fit(mat_sample_scaled)
|
||||||
|
coords_list = []
|
||||||
|
for start in range(0, len(df), BATCH_SIZE):
|
||||||
|
end = min(start + BATCH_SIZE, len(df))
|
||||||
|
mat_batch = scaler.transform(data_matrix[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)
|
||||||
|
else:
|
||||||
|
data_scaled = StandardScaler().fit_transform(data_matrix)
|
||||||
|
data_scaled = np.nan_to_num(data_scaled, nan=0.0, posinf=0.0, neginf=0.0)
|
||||||
|
reducer = umap.UMAP(n_components=2, random_state=RANDOM_SEED,
|
||||||
|
n_neighbors=15, min_dist=0.1,
|
||||||
|
metric='euclidean')
|
||||||
|
coords = reducer.fit_transform(data_scaled)
|
||||||
|
non_numeric = [c for c in df.columns if c not in numeric_cols and not c.startswith('_')]
|
||||||
|
entity_col_umap = non_numeric[0] if non_numeric else df.columns[0]
|
||||||
|
labels_list = cluster_entry.get('labels', [])
|
||||||
|
from analysis.models import EntityProfile as EP
|
||||||
|
from analysis.models import ClusterResult as CR
|
||||||
|
from analysis.models import AnalysisRun as AR
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
csv_glob = dataset_entry.get('metadata', {}).get('csv_glob', 'manual')
|
||||||
|
run_obj = await sync_to_async(
|
||||||
|
AR.objects.filter(csv_glob=csv_glob).order_by('-id').first,
|
||||||
|
thread_sensitive=True
|
||||||
|
)()
|
||||||
|
if run_obj:
|
||||||
|
run_id_for_ep = run_obj.id
|
||||||
|
profiles = []
|
||||||
|
cr_cache = {}
|
||||||
|
for i, row in enumerate(df.iter_rows(named=True)):
|
||||||
|
if i >= len(coords): break
|
||||||
|
ev = str(row.get(entity_col_umap, ''))
|
||||||
|
if not ev: continue
|
||||||
|
lbl = labels_list[i] if i < len(labels_list) else -1
|
||||||
|
cache_key = f'{run_id_for_ep}_{lbl}'
|
||||||
|
if cache_key not in cr_cache:
|
||||||
|
cr_cache[cache_key] = CR.objects.filter(run_id=run_id_for_ep, cluster_label=lbl).first()
|
||||||
|
profiles.append(EP(
|
||||||
|
entity_value=ev, run_id=run_id_for_ep, cluster_label=lbl,
|
||||||
|
cluster=cr_cache[cache_key],
|
||||||
|
embedding_x=float(coords[i, 0]),
|
||||||
|
embedding_y=float(coords[i, 1]),
|
||||||
|
))
|
||||||
|
if profiles:
|
||||||
|
EP.objects.bulk_create(profiles, ignore_conflicts=True, batch_size=1000)
|
||||||
|
except Exception:
|
||||||
|
print(f'[UMAP_ERR] {traceback.format_exc()}', file=sys.stderr)
|
||||||
|
|
||||||
|
# Persist to Django ORM
|
||||||
|
db_saved = False
|
||||||
|
if save_to_db:
|
||||||
|
try:
|
||||||
|
from asgiref.sync import sync_to_async
|
||||||
|
from analysis.models import AnalysisRun as AnalysisRunModel
|
||||||
|
ce = cluster_entry
|
||||||
|
async def _do_save(ce_inner):
|
||||||
|
if run_id is not None:
|
||||||
|
run = await sync_to_async(AnalysisRunModel.objects.get)(id=run_id)
|
||||||
else:
|
else:
|
||||||
score = abs(c_mean - gs['mean']) / max(c_std + gs['std'], 1e-10)
|
csv_glob_fallback = dataset_entry.get('metadata', {}).get('csv_glob', 'manual')
|
||||||
|
run = await sync_to_async(
|
||||||
|
AnalysisRunModel.objects.filter(csv_glob=csv_glob_fallback).order_by('-id').first,
|
||||||
|
thread_sensitive=True
|
||||||
|
)()
|
||||||
|
if run is None:
|
||||||
|
run = await sync_to_async(AnalysisRunModel.objects.create)(
|
||||||
|
csv_glob=csv_glob_fallback, status='running', total_flows=0,
|
||||||
|
)
|
||||||
|
run.status = 'completed'
|
||||||
|
run.cluster_count = ce_inner['n_clusters']
|
||||||
|
await sync_to_async(run.save)()
|
||||||
|
return await sync_to_async(_save_features_to_db)(cluster_result_id, all_features, run_id=run.id)
|
||||||
|
db_saved = await _do_save(ce)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[DB_SAVE_ERROR] {e}\n{traceback.format_exc()}', file=sys.stderr)
|
||||||
|
db_saved = False
|
||||||
|
|
||||||
cluster_features.append({
|
|
||||||
'feature_name': col,
|
|
||||||
'cluster_label': int(label),
|
|
||||||
'mean': round(c_mean, 4),
|
|
||||||
'std': round(c_std, 4),
|
|
||||||
'global_mean': round(gs['mean'], 4),
|
|
||||||
'global_std': round(gs['std'], 4),
|
|
||||||
'distinguishing_score': round(float(score), 4),
|
|
||||||
'distinguishing_method': method,
|
|
||||||
})
|
|
||||||
|
|
||||||
cluster_features.sort(key=lambda x: abs(x['distinguishing_score']), reverse=True)
|
|
||||||
all_features.extend(cluster_features[:top_k])
|
|
||||||
|
|
||||||
# ── Compute UMAP-2D embedding ──────────────────────────────────
|
|
||||||
if len(numeric_cols) >= 2 and len(df) > 2:
|
|
||||||
try:
|
|
||||||
from sklearn.preprocessing import StandardScaler
|
|
||||||
import umap
|
|
||||||
MAX_UMAP_TRAIN = 10000
|
|
||||||
BATCH_SIZE = 1000
|
|
||||||
data_matrix = df.select(numeric_cols).to_numpy()
|
|
||||||
if len(df) > MAX_UMAP_TRAIN:
|
|
||||||
idx_sample = np.random.RandomState(42).choice(
|
|
||||||
len(df), size=MAX_UMAP_TRAIN, replace=False)
|
|
||||||
mat_sample = data_matrix[idx_sample]
|
|
||||||
scaler = StandardScaler().fit(mat_sample)
|
|
||||||
mat_sample_scaled = np.nan_to_num(scaler.transform(mat_sample),
|
|
||||||
nan=0.0, posinf=0.0, neginf=0.0)
|
|
||||||
reducer = umap.UMAP(n_components=2, random_state=42,
|
|
||||||
n_neighbors=15, min_dist=0.1,
|
|
||||||
metric='euclidean')
|
|
||||||
reducer.fit(mat_sample_scaled)
|
|
||||||
coords_list = []
|
|
||||||
for start in range(0, len(df), BATCH_SIZE):
|
|
||||||
end = min(start + BATCH_SIZE, len(df))
|
|
||||||
mat_batch = scaler.transform(data_matrix[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)
|
|
||||||
else:
|
|
||||||
data_scaled = StandardScaler().fit_transform(data_matrix)
|
|
||||||
data_scaled = np.nan_to_num(data_scaled, nan=0.0, posinf=0.0, neginf=0.0)
|
|
||||||
reducer = umap.UMAP(n_components=2, random_state=42,
|
|
||||||
n_neighbors=15, min_dist=0.1,
|
|
||||||
metric='euclidean')
|
|
||||||
coords = reducer.fit_transform(data_scaled)
|
|
||||||
non_numeric = [c for c in df.columns if c not in numeric_cols and not c.startswith('_')]
|
|
||||||
entity_col_umap = non_numeric[0] if non_numeric else df.columns[0]
|
|
||||||
labels_list = cluster_entry.get('labels', [])
|
|
||||||
from analysis.models import EntityProfile as EP
|
|
||||||
from analysis.models import ClusterResult as CR
|
|
||||||
from analysis.models import AnalysisRun as AR
|
|
||||||
from asgiref.sync import sync_to_async
|
|
||||||
csv_glob = dataset_entry.get('metadata', {}).get('csv_glob', 'manual')
|
|
||||||
run_obj = await sync_to_async(
|
|
||||||
AR.objects.filter(csv_glob=csv_glob).order_by('-id').first,
|
|
||||||
thread_sensitive=True
|
|
||||||
)()
|
|
||||||
if run_obj:
|
|
||||||
run_id_for_ep = run_obj.id
|
|
||||||
profiles = []
|
|
||||||
cr_cache = {}
|
|
||||||
for i, row in enumerate(df.iter_rows(named=True)):
|
|
||||||
if i >= len(coords): break
|
|
||||||
ev = str(row.get(entity_col_umap, ''))
|
|
||||||
if not ev: continue
|
|
||||||
lbl = labels_list[i] if i < len(labels_list) else -1
|
|
||||||
cache_key = f'{run_id_for_ep}_{lbl}'
|
|
||||||
if cache_key not in cr_cache:
|
|
||||||
cr_cache[cache_key] = CR.objects.filter(run_id=run_id_for_ep, cluster_label=lbl).first()
|
|
||||||
profiles.append(EP(
|
|
||||||
entity_value=ev, run_id=run_id_for_ep, cluster_label=lbl,
|
|
||||||
cluster=cr_cache[cache_key],
|
|
||||||
embedding_x=float(coords[i, 0]),
|
|
||||||
embedding_y=float(coords[i, 1]),
|
|
||||||
))
|
|
||||||
if profiles:
|
|
||||||
EP.objects.bulk_create(profiles, ignore_conflicts=True, batch_size=1000)
|
|
||||||
except Exception:
|
|
||||||
import traceback, sys; print(f'[UMAP_ERR] {traceback.format_exc()}', file=sys.stderr)
|
|
||||||
|
|
||||||
# Persist to Django ORM
|
|
||||||
db_saved = False
|
|
||||||
if save_to_db:
|
|
||||||
try:
|
|
||||||
from asgiref.sync import sync_to_async
|
|
||||||
from analysis.models import AnalysisRun as AnalysisRunModel
|
|
||||||
ce = cluster_entry
|
|
||||||
async def _do_save(ce_inner):
|
|
||||||
if run_id is not None:
|
|
||||||
run = await sync_to_async(AnalysisRunModel.objects.get)(id=run_id)
|
|
||||||
else:
|
|
||||||
csv_glob_fallback = dataset_entry.get('metadata', {}).get('csv_glob', 'manual')
|
|
||||||
run = await sync_to_async(
|
|
||||||
AnalysisRunModel.objects.filter(csv_glob=csv_glob_fallback).order_by('-id').first,
|
|
||||||
thread_sensitive=True
|
|
||||||
)()
|
|
||||||
if run is None:
|
|
||||||
run = await sync_to_async(AnalysisRunModel.objects.create)(
|
|
||||||
csv_glob=csv_glob_fallback, status='running', total_flows=0,
|
|
||||||
)
|
|
||||||
run.status = 'completed'
|
|
||||||
run.cluster_count = ce_inner['n_clusters']
|
|
||||||
await sync_to_async(run.save)()
|
|
||||||
return await sync_to_async(_save_features_to_db)(cluster_result_id, all_features, run_id=run.id)
|
|
||||||
db_saved = await _do_save(ce)
|
|
||||||
except Exception as e:
|
|
||||||
import traceback, sys; print(f'[DB_SAVE_ERROR] {e}\n{traceback.format_exc()}', file=sys.stderr)
|
|
||||||
db_saved = False
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
raise
|
|
||||||
|
|
||||||
result_id = _generate_id('feat')
|
result_id = _generate_id('feat')
|
||||||
store.store_feature_result(
|
store.store_feature_result(
|
||||||
@@ -251,6 +254,7 @@ def _save_features_to_db(
|
|||||||
cluster_count=n_clusters_global,
|
cluster_count=n_clusters_global,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
from analysis.models import ClusterResult as ClusterResultModel
|
from analysis.models import ClusterResult as ClusterResultModel
|
||||||
@@ -302,6 +306,7 @@ def _save_features_to_db(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
run.cluster_count = n_clusters
|
run.cluster_count = n_clusters
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""filter_data handler — apply column-level filters to a dataset."""
|
"""filter_data handler — apply column-level filters to a dataset."""
|
||||||
|
import traceback
|
||||||
import polars as pl
|
import polars as pl
|
||||||
|
|
||||||
from ..session_store import SessionStore, _generate_id
|
from ..session_store import SessionStore, _generate_id
|
||||||
from ._helpers import _build_filter_expr, _resolve_dataset
|
from ._helpers import _build_filter_expr, _resolve_dataset
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_filter_data(
|
async def _handle_filter_data(
|
||||||
@@ -40,6 +43,7 @@ async def _handle_filter_data(
|
|||||||
try:
|
try:
|
||||||
row_count = filtered_lf.select(pl.len()).collect(streaming=True).item()
|
row_count = filtered_lf.select(pl.len()).collect(streaming=True).item()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
row_count = None
|
row_count = None
|
||||||
|
|
||||||
new_id = _generate_id('flt')
|
new_id = _generate_id('flt')
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import polars as pl
|
|||||||
|
|
||||||
from ..data_loader import load_csv_directory
|
from ..data_loader import load_csv_directory
|
||||||
from ..session_store import SessionStore, _generate_id
|
from ..session_store import SessionStore, _generate_id
|
||||||
|
from analysis.constants import MAX_DISTRIBUTION_SAMPLE
|
||||||
|
|
||||||
|
|
||||||
async def _handle_load_data(
|
async def _handle_load_data(
|
||||||
@@ -24,7 +25,7 @@ async def _handle_load_data(
|
|||||||
encoding=encoding,
|
encoding=encoding,
|
||||||
delimiter=delimiter,
|
delimiter=delimiter,
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
sample_rows=10000,
|
sample_rows=MAX_DISTRIBUTION_SAMPLE,
|
||||||
schema_strict=schema_strict,
|
schema_strict=schema_strict,
|
||||||
recursive=recursive,
|
recursive=recursive,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,3 @@ from .globe import globe_view, _extract_flows_from_df
|
|||||||
from .config import config_view, llm_test
|
from .config import config_view, llm_test
|
||||||
from .log_viewer import log_viewer
|
from .log_viewer import log_viewer
|
||||||
from .tools import tool_lab, tool_lab_run, tool_plan, apply_filter, reload_run_data, retry_run
|
from .tools import tool_lab, tool_lab_run, tool_plan, apply_filter, reload_run_data, retry_run
|
||||||
|
|
||||||
# Auto-profile all sub-modules
|
|
||||||
from analysis.profile_util import auto_profile_module
|
|
||||||
auto_profile_module(__name__)
|
|
||||||
|
|||||||
@@ -130,11 +130,13 @@ def run_llm_analysis_view(request):
|
|||||||
try:
|
try:
|
||||||
lf = load_from_db(run.sqlite_table)
|
lf = load_from_db(run.sqlite_table)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
pass
|
pass
|
||||||
if lf is None and run.csv_glob:
|
if lf is None and run.csv_glob:
|
||||||
try:
|
try:
|
||||||
lf, schema, _, _, _ = load_csv_directory(run.csv_glob)
|
lf, schema, _, _, _ = load_csv_directory(run.csv_glob)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
pass
|
pass
|
||||||
if lf is None:
|
if lf is None:
|
||||||
return JsonResponse({'error': '数据集不存在,请先上传并等待预处理完成'})
|
return JsonResponse({'error': '数据集不存在,请先上传并等待预处理完成'})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Cluster views: overview, detail, globe flows, and the clustering pipeline."""
|
"""Cluster views: overview, detail, globe flows, and the clustering pipeline."""
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import traceback
|
import traceback
|
||||||
import logging
|
import logging
|
||||||
@@ -8,6 +9,7 @@ from django.http import Http404
|
|||||||
|
|
||||||
from analysis.models import AnalysisRun, ClusterResult, EntityProfile
|
from analysis.models import AnalysisRun, ClusterResult, EntityProfile
|
||||||
from analysis import nl_describe
|
from analysis import nl_describe
|
||||||
|
from analysis.constants import RANDOM_SEED, UMAP_BATCH_SIZE, UMAP_TRAIN_SAMPLE
|
||||||
from analysis.profile_util import profile
|
from analysis.profile_util import profile
|
||||||
|
|
||||||
from .helpers import _extract_lat, _extract_lon
|
from .helpers import _extract_lat, _extract_lon
|
||||||
@@ -127,9 +129,9 @@ def _get_globe_flows(run, max_rows=500):
|
|||||||
if lf is None:
|
if lf is None:
|
||||||
return []
|
return []
|
||||||
df = lf.head(max_rows).collect()
|
df = lf.head(max_rows).collect()
|
||||||
|
from analysis.type_classifier import TLS_HEX_MAP
|
||||||
from analysis.geoip import lookup as geo_lookup
|
from analysis.geoip import lookup as geo_lookup
|
||||||
flows = []
|
flows = []
|
||||||
TLS_HEX_MAP = {'0303': 'TLSv1.2', '0304': 'TLSv1.3', '0302': 'TLSv1.1', '0301': 'TLSv1.0'}
|
|
||||||
src_ip_col = next((c for c in df.columns if 'src_ip' in c.lower() or ':ips' in c.lower()), None)
|
src_ip_col = next((c for c in df.columns if 'src_ip' in c.lower() or ':ips' in c.lower()), None)
|
||||||
dst_ip_col = next((c for c in df.columns if 'dst_ip' in c.lower() or ':ipd' in c.lower()), None)
|
dst_ip_col = next((c for c in df.columns if 'dst_ip' in c.lower() or ':ipd' in c.lower()), None)
|
||||||
tls_col = next((c for c in df.columns if c.lower() in ('0ver', 'tls_version', '_tlsver', 'version')), None)
|
tls_col = next((c for c in df.columns if c.lower() in ('0ver', 'tls_version', '_tlsver', 'version')), None)
|
||||||
@@ -153,6 +155,7 @@ def _get_globe_flows(run, max_rows=500):
|
|||||||
})
|
})
|
||||||
return flows
|
return flows
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -209,7 +212,6 @@ def _run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorith
|
|||||||
"""
|
"""
|
||||||
import warnings
|
import warnings
|
||||||
warnings.filterwarnings('ignore', category=RuntimeWarning, module='sklearn')
|
warnings.filterwarnings('ignore', category=RuntimeWarning, module='sklearn')
|
||||||
import asyncio
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from analysis.tool_registry import _handle_run_clustering, _handle_extract_features
|
from analysis.tool_registry import _handle_run_clustering, _handle_extract_features
|
||||||
@@ -230,6 +232,7 @@ def _run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorith
|
|||||||
try:
|
try:
|
||||||
row_count = lf.select(pl.len()).collect(streaming=True).item()
|
row_count = lf.select(pl.len()).collect(streaming=True).item()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
row_count = 0
|
row_count = 0
|
||||||
|
|
||||||
if row_count > head:
|
if row_count > head:
|
||||||
@@ -280,7 +283,7 @@ def _run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorith
|
|||||||
cluster_columns=feature_cols,
|
cluster_columns=feature_cols,
|
||||||
algorithm=algorithm,
|
algorithm=algorithm,
|
||||||
params={'min_cluster_size': min_cluster_size},
|
params={'min_cluster_size': min_cluster_size},
|
||||||
random_state=42,
|
random_state=RANDOM_SEED,
|
||||||
))
|
))
|
||||||
|
|
||||||
if 'error' in result:
|
if 'error' in result:
|
||||||
@@ -357,11 +360,12 @@ def _run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorith
|
|||||||
try:
|
try:
|
||||||
lf = entry['lazyframe']
|
lf = entry['lazyframe']
|
||||||
# UMAP: sample max 10K for training, batch-transform rest in 1K batches
|
# UMAP: sample max 10K for training, batch-transform rest in 1K batches
|
||||||
MAX_UMAP_TRAIN = 10000
|
MAX_UMAP_TRAIN = UMAP_TRAIN_SAMPLE
|
||||||
BATCH_SIZE = 1000
|
BATCH_SIZE = UMAP_BATCH_SIZE
|
||||||
try:
|
try:
|
||||||
n_total = lf.select(pl.len()).collect(streaming=True).item()
|
n_total = lf.select(pl.len()).collect(streaming=True).item()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
n_total = 0
|
n_total = 0
|
||||||
from sklearn.preprocessing import StandardScaler
|
from sklearn.preprocessing import StandardScaler
|
||||||
import umap
|
import umap
|
||||||
@@ -377,7 +381,7 @@ def _run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorith
|
|||||||
if n_total > MAX_UMAP_TRAIN:
|
if n_total > MAX_UMAP_TRAIN:
|
||||||
run.progress_msg = f'UMAP 采样 {MAX_UMAP_TRAIN}/{n_total} 实体训练...'
|
run.progress_msg = f'UMAP 采样 {MAX_UMAP_TRAIN}/{n_total} 实体训练...'
|
||||||
run.save(update_fields=['progress_msg'])
|
run.save(update_fields=['progress_msg'])
|
||||||
df_sample = lf.sample(n=MAX_UMAP_TRAIN, seed=42).collect(streaming=True)
|
df_sample = lf.sample(n=MAX_UMAP_TRAIN, seed=RANDOM_SEED).collect(streaming=True)
|
||||||
df_umap = lf.collect(streaming=True)
|
df_umap = lf.collect(streaming=True)
|
||||||
else:
|
else:
|
||||||
df_umap = lf.collect(streaming=True)
|
df_umap = lf.collect(streaming=True)
|
||||||
@@ -396,7 +400,7 @@ def _run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorith
|
|||||||
mat_sample_scaled = np.nan_to_num(
|
mat_sample_scaled = np.nan_to_num(
|
||||||
scaler.transform(mat_sample), nan=0.0)
|
scaler.transform(mat_sample), nan=0.0)
|
||||||
reducer = umap.UMAP(n_components=attempt_n,
|
reducer = umap.UMAP(n_components=attempt_n,
|
||||||
random_state=42,
|
random_state=RANDOM_SEED,
|
||||||
n_neighbors=15, min_dist=0.1,
|
n_neighbors=15, min_dist=0.1,
|
||||||
metric='euclidean')
|
metric='euclidean')
|
||||||
reducer.fit(mat_sample_scaled)
|
reducer.fit(mat_sample_scaled)
|
||||||
@@ -413,7 +417,7 @@ def _run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorith
|
|||||||
mat = np.nan_to_num(StandardScaler().fit_transform(
|
mat = np.nan_to_num(StandardScaler().fit_transform(
|
||||||
df_umap.select(num_cols).to_numpy()), nan=0.0)
|
df_umap.select(num_cols).to_numpy()), nan=0.0)
|
||||||
reducer = umap.UMAP(n_components=attempt_n,
|
reducer = umap.UMAP(n_components=attempt_n,
|
||||||
random_state=42,
|
random_state=RANDOM_SEED,
|
||||||
n_neighbors=15, min_dist=0.1,
|
n_neighbors=15, min_dist=0.1,
|
||||||
metric='euclidean')
|
metric='euclidean')
|
||||||
coords = reducer.fit_transform(mat)
|
coords = reducer.fit_transform(mat)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Configuration views: config editor and LLM connectivity test."""
|
"""Configuration views: config editor and LLM connectivity test."""
|
||||||
|
import traceback
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -112,6 +113,7 @@ def llm_test(request):
|
|||||||
detail = json.loads(e.read().decode('utf-8', errors='replace'))
|
detail = json.loads(e.read().decode('utf-8', errors='replace'))
|
||||||
msg = detail.get('error', {}).get('message', str(e))
|
msg = detail.get('error', {}).get('message', str(e))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
msg = str(e)
|
msg = str(e)
|
||||||
logger.info(f'[LLM_TEST] base_url={base_url} model={model} success=False latency={elapsed}ms')
|
logger.info(f'[LLM_TEST] base_url={base_url} model={model} success=False latency={elapsed}ms')
|
||||||
return JsonResponse({'success': False, 'message': f'HTTP {e.code}: {msg}', 'latency_ms': elapsed})
|
return JsonResponse({'success': False, 'message': f'HTTP {e.code}: {msg}', 'latency_ms': elapsed})
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""3D Globe view and flow extraction."""
|
"""3D Globe view and flow extraction."""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import traceback
|
||||||
|
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
from analysis.constants import GLOBE_MAX_ROWS
|
||||||
from analysis.models import AnalysisRun
|
from analysis.models import AnalysisRun
|
||||||
from .helpers import _extract_lat, _extract_lon
|
from .helpers import _extract_lat, _extract_lon
|
||||||
|
|
||||||
@@ -13,8 +15,7 @@ logger = logging.getLogger(__name__)
|
|||||||
def _extract_flows_from_df(df, MAX_ROWS):
|
def _extract_flows_from_df(df, MAX_ROWS):
|
||||||
"""Extract flow data (lat/lon/TLS/bytes) from a Polars DataFrame."""
|
"""Extract flow data (lat/lon/TLS/bytes) from a Polars DataFrame."""
|
||||||
from analysis.geoip import lookup as geo_lookup
|
from analysis.geoip import lookup as geo_lookup
|
||||||
TLS_HEX_MAP = {'0303': 'TLSv1.2', '0304': 'TLSv1.3', '0302': 'TLSv1.1', '0301': 'TLSv1.0'}
|
from analysis.type_classifier import TLS_HEX_MAP
|
||||||
# NOTE: Duplicated in type_classifier.py — keep both in sync.
|
|
||||||
|
|
||||||
src_lat_col = next((c for c in df.columns if c.lower() in (
|
src_lat_col = next((c for c in df.columns if c.lower() in (
|
||||||
':ips.latd', ':ips_latd', ':ips_lat', 'src_latitude', 'src_lat', 'source_latitude', 'source_lat')), None)
|
':ips.latd', ':ips_latd', ':ips_lat', 'src_latitude', 'src_lat', 'source_latitude', 'source_lat')), None)
|
||||||
@@ -117,7 +118,7 @@ def globe_view(request):
|
|||||||
from analysis.models import AnalysisRun
|
from analysis.models import AnalysisRun
|
||||||
from analysis.session_store import SessionStore
|
from analysis.session_store import SessionStore
|
||||||
|
|
||||||
MAX_ROWS_PER_RUN = 300 # Limit per run to prevent browser overload
|
MAX_ROWS_PER_RUN = GLOBE_MAX_ROWS # Limit per run to prevent browser overload
|
||||||
|
|
||||||
# Check for ?data=dataset_id parameter (direct data loading from session store)
|
# Check for ?data=dataset_id parameter (direct data loading from session store)
|
||||||
data_param = request.GET.get('data')
|
data_param = request.GET.get('data')
|
||||||
@@ -197,6 +198,7 @@ def globe_view(request):
|
|||||||
selected_runs = [latest]
|
selected_runs = [latest]
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
resp = render(request, 'tianxuan/globe.html', {
|
resp = render(request, 'tianxuan/globe.html', {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"""Geo extraction helpers and plan utilities."""
|
"""Geo extraction helpers and plan utilities."""
|
||||||
|
import traceback
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json as _json
|
import json as _json
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _extract_lat(val):
|
def _extract_lat(val):
|
||||||
@@ -45,6 +48,7 @@ def _read_auto_index():
|
|||||||
return []
|
return []
|
||||||
return data
|
return data
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("_read_auto_index failed: {}".format(traceback.format_exc()))
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import traceback
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from analysis.appdata import ensure_appdata_dir
|
from analysis.appdata import ensure_appdata_dir
|
||||||
|
from analysis.constants import MAX_CLUSTER_FEATURES, RANDOM_SEED
|
||||||
from analysis.models import AnalysisRun
|
from analysis.models import AnalysisRun
|
||||||
from analysis.profile_util import profile
|
from analysis.profile_util import profile
|
||||||
|
|
||||||
@@ -31,8 +32,7 @@ def _run_pipeline_worker(run_id, analysis_fn, **ctx):
|
|||||||
try:
|
try:
|
||||||
analysis_fn(run, ctx)
|
analysis_fn(run, ctx)
|
||||||
except Exception:
|
except Exception:
|
||||||
import traceback as _tb
|
tb = traceback.format_exc()
|
||||||
tb = _tb.format_exc()
|
|
||||||
logger.error(tb)
|
logger.error(tb)
|
||||||
run.status = 'failed'
|
run.status = 'failed'
|
||||||
run.error_message = tb
|
run.error_message = tb
|
||||||
@@ -208,6 +208,7 @@ def _background_process(run_id, upload_dir):
|
|||||||
break
|
break
|
||||||
type_map[col] = pl.Int64 if all_int else pl.Float64
|
type_map[col] = pl.Int64 if all_int else pl.Float64
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if type_map:
|
if type_map:
|
||||||
@@ -229,7 +230,7 @@ def _background_process(run_id, upload_dir):
|
|||||||
n_numeric = len(numeric_cols)
|
n_numeric = len(numeric_cols)
|
||||||
|
|
||||||
if n_numeric >= 2:
|
if n_numeric >= 2:
|
||||||
n_components = min(50, n_numeric - 1)
|
n_components = min(MAX_CLUSTER_FEATURES, n_numeric - 1)
|
||||||
try:
|
try:
|
||||||
from sklearn.decomposition import TruncatedSVD # fmt: skip
|
from sklearn.decomposition import TruncatedSVD # fmt: skip
|
||||||
import numpy as np # fmt: skip
|
import numpy as np # fmt: skip
|
||||||
@@ -239,7 +240,7 @@ def _background_process(run_id, upload_dir):
|
|||||||
X = df_full.to_numpy()
|
X = df_full.to_numpy()
|
||||||
X = np.nan_to_num(X, nan=0.0, posinf=0.0, neginf=0.0)
|
X = np.nan_to_num(X, nan=0.0, posinf=0.0, neginf=0.0)
|
||||||
|
|
||||||
svd = TruncatedSVD(n_components=n_components, random_state=42)
|
svd = TruncatedSVD(n_components=n_components, random_state=RANDOM_SEED)
|
||||||
X_svd = svd.fit_transform(X)
|
X_svd = svd.fit_transform(X)
|
||||||
svd_components = n_components
|
svd_components = n_components
|
||||||
|
|
||||||
@@ -291,6 +292,7 @@ def _background_process(run_id, upload_dir):
|
|||||||
from analysis.data_loader import drop_sqlite_table # fmt: skip
|
from analysis.data_loader import drop_sqlite_table # fmt: skip
|
||||||
drop_sqlite_table(f'_data_{run_id}')
|
drop_sqlite_table(f'_data_{run_id}')
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
pass # Expected: drop_sqlite_table may fail if table already gone
|
pass # Expected: drop_sqlite_table may fail if table already gone
|
||||||
run.status = 'failed'
|
run.status = 'failed'
|
||||||
run.error_message = tb
|
run.error_message = tb
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ def tool_lab_run(request):
|
|||||||
try:
|
try:
|
||||||
body = _json.loads(request.body)
|
body = _json.loads(request.body)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("tool_lab_run failed: {}".format(traceback.format_exc()))
|
||||||
return JsonResponse({'error': 'Invalid JSON body'}, status=400)
|
return JsonResponse({'error': 'Invalid JSON body'}, status=400)
|
||||||
|
|
||||||
tool_name = body.get('tool')
|
tool_name = body.get('tool')
|
||||||
@@ -120,6 +121,7 @@ def reload_run_data(request):
|
|||||||
try:
|
try:
|
||||||
body = _json.loads(request.body)
|
body = _json.loads(request.body)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("reload_run_data failed: {}".format(traceback.format_exc()))
|
||||||
return JsonResponse({'error': 'Invalid JSON body'}, status=400)
|
return JsonResponse({'error': 'Invalid JSON body'}, status=400)
|
||||||
|
|
||||||
display_id = body.get('display_id')
|
display_id = body.get('display_id')
|
||||||
@@ -221,6 +223,7 @@ def tool_plan(request):
|
|||||||
steps = len(content.get('steps', []))
|
steps = len(content.get('steps', []))
|
||||||
plans.append({'name': p.stem, 'steps': steps})
|
plans.append({'name': p.stem, 'steps': steps})
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
plans.append({'name': p.stem, 'steps': 0})
|
plans.append({'name': p.stem, 'steps': 0})
|
||||||
|
|
||||||
# Add auto plans with markers
|
# Add auto plans with markers
|
||||||
@@ -237,6 +240,7 @@ def tool_plan(request):
|
|||||||
'pinned': auto_index_map.get(auto_name, False),
|
'pinned': auto_index_map.get(auto_name, False),
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
auto_plans.append({
|
auto_plans.append({
|
||||||
'name': auto_name.replace('.json', ''),
|
'name': auto_name.replace('.json', ''),
|
||||||
'steps': 0,
|
'steps': 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user