feat(clustering): dégradé HSL multi-stop basé sur le score de non-humanité
Remplace la palette par index par un dégradé continu HSL : - risk=0.0 → hue 220° (bleu froid — humain légitime) - risk=0.25 → hue 165° (cyan-vert — légèrement suspect) - risk=0.50 → hue 110° (vert-jaune — comportement mixte) - risk=0.75 → hue 55° (jaune-orange — probable bot) - risk=1.0 → hue 0° (rouge vif — bot confirmé) Saturation : 70%→90%, Lightness : 58%→48% (couleur plus intense = plus alarmant) Légende : barre de dégradé 'Humain ← → Bot' avec stops HSL alignés Suppression de spread_clusters (chevauchement des zones autorisé) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@ -24,7 +24,7 @@ from ..services.clustering_engine import (
|
|||||||
FEATURE_KEYS, FEATURE_NAMES, FEATURE_NORMS, N_FEATURES,
|
FEATURE_KEYS, FEATURE_NAMES, FEATURE_NORMS, N_FEATURES,
|
||||||
build_feature_vector, kmeans_pp, pca_2d, compute_hulls,
|
build_feature_vector, kmeans_pp, pca_2d, compute_hulls,
|
||||||
name_cluster, risk_score_from_centroid, standardize,
|
name_cluster, risk_score_from_centroid, standardize,
|
||||||
cluster_color, spread_clusters,
|
risk_to_gradient_color,
|
||||||
)
|
)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@ -196,11 +196,7 @@ def _run_clustering_job(k: int, hours: int, sensitivity: float = 1.0) -> None:
|
|||||||
# ── 5. PCA-2D sur les features ORIGINALES (normalisées [0,1]) ────
|
# ── 5. PCA-2D sur les features ORIGINALES (normalisées [0,1]) ────
|
||||||
coords = pca_2d(X64) # (n, 2), normalisé [0,1]
|
coords = pca_2d(X64) # (n, 2), normalisé [0,1]
|
||||||
|
|
||||||
# ── 5b. Dispersion — repousse les clusters trop proches ──────────
|
# ── 5b. Enveloppes convexes par cluster ──────────────────────────
|
||||||
coords = spread_clusters(coords, km.labels, k_actual,
|
|
||||||
n_iter=60, min_dist=0.16)
|
|
||||||
|
|
||||||
# ── 5c. Enveloppes convexes par cluster ──────────────────────────
|
|
||||||
hulls = compute_hulls(coords, km.labels, k_actual)
|
hulls = compute_hulls(coords, km.labels, k_actual)
|
||||||
|
|
||||||
# ── 6. Agrégation par cluster ─────────────────────────────────────
|
# ── 6. Agrégation par cluster ─────────────────────────────────────
|
||||||
@ -237,7 +233,7 @@ def _run_clustering_job(k: int, hours: int, sensitivity: float = 1.0) -> None:
|
|||||||
raw_stats = {"mean_ttl": mean_ttl, "mean_mss": mean_mss, "mean_scale": mean_scale}
|
raw_stats = {"mean_ttl": mean_ttl, "mean_mss": mean_mss, "mean_scale": mean_scale}
|
||||||
label_name = name_cluster(centroids_orig[j], raw_stats)
|
label_name = name_cluster(centroids_orig[j], raw_stats)
|
||||||
risk = float(risk_score_from_centroid(centroids_orig[j]))
|
risk = float(risk_score_from_centroid(centroids_orig[j]))
|
||||||
color = cluster_color(j)
|
color = risk_to_gradient_color(risk)
|
||||||
|
|
||||||
# Centroïde 2D = moyenne des coords du cluster
|
# Centroïde 2D = moyenne des coords du cluster
|
||||||
cxy = np.mean(cluster_coords[j], axis=0).tolist() if cluster_coords[j] else [0.5, 0.5]
|
cxy = np.mean(cluster_coords[j], axis=0).tolist() if cluster_coords[j] else [0.5, 0.5]
|
||||||
|
|||||||
@ -452,96 +452,42 @@ def risk_score_from_centroid(centroid: np.ndarray) -> float:
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
# ─── Palette de couleurs diversifiée (non liée au risque) ────────────────────
|
# ─── Gradient de couleur basé sur le score de non-humanité ──────────────────
|
||||||
# 24 couleurs couvrant tout le spectre HSL pour distinguer les clusters visuellement.
|
# Le score [0,1] est mappé sur un dégradé HSL traversant tout le spectre :
|
||||||
# Choix: teintes espacées de ~15° avec alternance de saturation/luminosité.
|
# bleu (humain) → cyan → vert → jaune-vert → jaune → orange → rouge (bot pur)
|
||||||
|
# Hue : 220° (bleu froid) → 0° (rouge vif) en passant par tout l'arc chromatique.
|
||||||
|
|
||||||
_CLUSTER_PALETTE: list[str] = [
|
def _hsl_to_hex(h: float, s: float, l: float) -> str:
|
||||||
"#3b82f6", # blue
|
"""Convertit HSL (h:0-360, s:0-100, l:0-100) en chaîne '#rrggbb'."""
|
||||||
"#8b5cf6", # violet
|
s /= 100.0
|
||||||
"#ec4899", # pink
|
l /= 100.0
|
||||||
"#14b8a6", # teal
|
c = (1.0 - abs(2.0 * l - 1.0)) * s
|
||||||
"#f59e0b", # amber
|
x = c * (1.0 - abs((h / 60.0) % 2.0 - 1.0))
|
||||||
"#06b6d4", # cyan
|
m = l - c / 2.0
|
||||||
"#a3e635", # lime
|
if h < 60: r, g, b = c, x, 0.0
|
||||||
"#f97316", # orange
|
elif h < 120: r, g, b = x, c, 0.0
|
||||||
"#6366f1", # indigo
|
elif h < 180: r, g, b = 0.0, c, x
|
||||||
"#10b981", # emerald
|
elif h < 240: r, g, b = 0.0, x, c
|
||||||
"#e879f9", # fuchsia
|
elif h < 300: r, g, b = x, 0.0, c
|
||||||
"#fbbf24", # yellow
|
else: r, g, b = c, 0.0, x
|
||||||
"#60a5fa", # light blue
|
ri, gi, bi = int((r + m) * 255), int((g + m) * 255), int((b + m) * 255)
|
||||||
"#c084fc", # light purple
|
return f"#{ri:02x}{gi:02x}{bi:02x}"
|
||||||
"#fb7185", # rose
|
|
||||||
"#34d399", # light green
|
|
||||||
"#38bdf8", # sky
|
|
||||||
"#a78bfa", # lavender
|
|
||||||
"#fdba74", # peach
|
|
||||||
"#4ade80", # green
|
|
||||||
"#f472b6", # light pink
|
|
||||||
"#67e8f9", # light cyan
|
|
||||||
"#d97706", # dark amber
|
|
||||||
"#7c3aed", # dark violet
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def cluster_color(cluster_idx: int) -> str:
|
def risk_to_gradient_color(risk: float) -> str:
|
||||||
"""Couleur distinctive pour un cluster, cyclique sur la palette."""
|
|
||||||
return _CLUSTER_PALETTE[cluster_idx % len(_CLUSTER_PALETTE)]
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Dispersion des clusters dans l'espace 2D ────────────────────────────────
|
|
||||||
|
|
||||||
def spread_clusters(coords_2d: np.ndarray, labels: np.ndarray, k: int,
|
|
||||||
n_iter: int = 50, min_dist: float = 0.14) -> np.ndarray:
|
|
||||||
"""
|
"""
|
||||||
Repousse les centroïdes trop proches par répulsion itérative (spring repulsion).
|
Mappe un score de non-humanité [0,1] sur un dégradé HSL continu multi-stop.
|
||||||
Chaque point suit le déplacement de son centroïde.
|
|
||||||
|
|
||||||
Paramètres
|
risk = 0.0 → hue 220° (bleu froid — trafic humain légitime)
|
||||||
----------
|
risk = 0.25 → hue 165° (cyan-vert — léger signal suspect)
|
||||||
min_dist : distance minimale souhaitée entre centroïdes (espace [0,1]).
|
risk = 0.50 → hue 110° (vert-jaune — comportement mixte)
|
||||||
Augmenter pour plus d'éclatement.
|
risk = 0.75 → hue 55° (jaune-orange — probable bot)
|
||||||
n_iter : nombre d'itérations de la physique de répulsion.
|
risk = 1.0 → hue 0° (rouge vif — bot confirmé)
|
||||||
|
|
||||||
|
La saturation monte légèrement avec le risque pour accentuer la lisibilité.
|
||||||
"""
|
"""
|
||||||
rng = np.random.default_rng(0)
|
r = float(np.clip(risk, 0.0, 1.0))
|
||||||
centroids = np.zeros((k, 2))
|
hue = (1.0 - r) * 220.0 # 220° → 0°
|
||||||
counts = np.zeros(k, dtype=int)
|
saturation = 70.0 + r * 20.0 # 70% → 90%
|
||||||
for j in range(k):
|
lightness = 58.0 - r * 10.0 # 58% → 48% (plus sombre = plus alarmant)
|
||||||
mask = labels == j
|
return _hsl_to_hex(hue, saturation, lightness)
|
||||||
if mask.any():
|
|
||||||
centroids[j] = coords_2d[mask].mean(axis=0)
|
|
||||||
counts[j] = int(mask.sum())
|
|
||||||
|
|
||||||
orig = centroids.copy()
|
|
||||||
|
|
||||||
for _ in range(n_iter):
|
|
||||||
forces = np.zeros_like(centroids)
|
|
||||||
for i in range(k):
|
|
||||||
if counts[i] == 0:
|
|
||||||
continue
|
|
||||||
for j in range(k):
|
|
||||||
if i == j or counts[j] == 0:
|
|
||||||
continue
|
|
||||||
delta = centroids[i] - centroids[j]
|
|
||||||
dist = float(np.linalg.norm(delta))
|
|
||||||
if dist < 1e-8:
|
|
||||||
delta = rng.uniform(-0.02, 0.02, size=2)
|
|
||||||
dist = float(np.linalg.norm(delta)) + 1e-8
|
|
||||||
if dist < min_dist:
|
|
||||||
# Force inversement proportionnelle à l'écart
|
|
||||||
magnitude = (min_dist - dist) / min_dist
|
|
||||||
forces[i] += magnitude * (delta / dist)
|
|
||||||
centroids += forces * 0.10
|
|
||||||
|
|
||||||
# Déplace chaque point par le delta de son centroïde
|
|
||||||
displaced = coords_2d.copy()
|
|
||||||
for j in range(k):
|
|
||||||
if counts[j] == 0:
|
|
||||||
continue
|
|
||||||
displaced[labels == j] += centroids[j] - orig[j]
|
|
||||||
|
|
||||||
# Re-normalisation [0, 1]
|
|
||||||
mn, mx = displaced.min(axis=0), displaced.max(axis=0)
|
|
||||||
rng_ = mx - mn
|
|
||||||
rng_[rng_ < 1e-8] = 1.0
|
|
||||||
return (displaced - mn) / rng_
|
|
||||||
|
|||||||
@ -519,20 +519,19 @@ export default function ClusteringView() {
|
|||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
controller={true}
|
controller={true}
|
||||||
>
|
>
|
||||||
{/* Légende overlay */}
|
{/* Légende overlay — gradient non-humanité */}
|
||||||
<div style={{ position: 'absolute', bottom: 16, left: 16, pointerEvents: 'all' }}>
|
<div style={{ position: 'absolute', bottom: 16, left: 16, pointerEvents: 'none' }}>
|
||||||
<div className="bg-black/70 rounded-lg p-2 text-xs flex flex-col gap-1">
|
<div className="bg-black/70 rounded-lg p-2 text-xs flex flex-col gap-1.5">
|
||||||
<div className="text-white/50 text-[10px] uppercase tracking-wide mb-1">Clusters</div>
|
<div className="text-white/50 text-[10px] uppercase tracking-wide">Non-humanité</div>
|
||||||
{data?.nodes?.slice(0, 6).map((n) => (
|
{/* Barre de dégradé bleu → rouge */}
|
||||||
<div key={n.id} className="flex items-center gap-2">
|
<div className="relative w-28 h-3 rounded-full overflow-hidden"
|
||||||
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ background: n.color }} />
|
style={{ background: 'linear-gradient(to right, hsl(220,70%,58%), hsl(165,78%,53%), hsl(110,82%,52%), hsl(55,86%,52%), hsl(0,90%,48%)' }}>
|
||||||
<span className="text-white/70 truncate max-w-[120px]">{n.label}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="flex justify-between w-28 text-[9px] text-white/50">
|
||||||
{(data?.nodes?.length ?? 0) > 6 && (
|
<span>Humain</span>
|
||||||
<div className="text-white/30 text-[10px]">+{(data?.nodes?.length ?? 0) - 6} autres…</div>
|
<span>Bot</span>
|
||||||
)}
|
</div>
|
||||||
<div className="mt-1 pt-1 border-t border-white/10 text-white/40 text-[10px] cursor-help" title={TIPS.features_31}>
|
<div className="mt-0.5 pt-1 border-t border-white/10 text-white/40 text-[10px] cursor-help" title={TIPS.features_31}>
|
||||||
30 features · PCA 2D ⓘ
|
30 features · PCA 2D ⓘ
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user