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:
SOC Analyst
2026-03-19 14:23:27 +01:00
parent 08d003a050
commit 2f73860cc8
3 changed files with 49 additions and 108 deletions

View File

@ -24,7 +24,7 @@ from ..services.clustering_engine import (
FEATURE_KEYS, FEATURE_NAMES, FEATURE_NORMS, N_FEATURES,
build_feature_vector, kmeans_pp, pca_2d, compute_hulls,
name_cluster, risk_score_from_centroid, standardize,
cluster_color, spread_clusters,
risk_to_gradient_color,
)
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]) ────
coords = pca_2d(X64) # (n, 2), normalisé [0,1]
# ── 5b. Dispersion — repousse les clusters trop proches ──────────
coords = spread_clusters(coords, km.labels, k_actual,
n_iter=60, min_dist=0.16)
# ── 5c. Enveloppes convexes par cluster ──────────────────────────
# ── 5b. Enveloppes convexes par cluster ──────────────────────────
hulls = compute_hulls(coords, km.labels, k_actual)
# ── 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}
label_name = name_cluster(centroids_orig[j], raw_stats)
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
cxy = np.mean(cluster_coords[j], axis=0).tolist() if cluster_coords[j] else [0.5, 0.5]

View File

@ -452,96 +452,42 @@ def risk_score_from_centroid(centroid: np.ndarray) -> float:
))
# ─── Palette de couleurs diversifiée (non liée au risque) ────────────────────
# 24 couleurs couvrant tout le spectre HSL pour distinguer les clusters visuellement.
# Choix: teintes espacées de ~15° avec alternance de saturation/luminosité.
# ─── Gradient de couleur basé sur le score de non-humanité ──────────────────
# Le score [0,1] est mappé sur un dégradé HSL traversant tout le spectre :
# 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] = [
"#3b82f6", # blue
"#8b5cf6", # violet
"#ec4899", # pink
"#14b8a6", # teal
"#f59e0b", # amber
"#06b6d4", # cyan
"#a3e635", # lime
"#f97316", # orange
"#6366f1", # indigo
"#10b981", # emerald
"#e879f9", # fuchsia
"#fbbf24", # yellow
"#60a5fa", # light blue
"#c084fc", # light purple
"#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 _hsl_to_hex(h: float, s: float, l: float) -> str:
"""Convertit HSL (h:0-360, s:0-100, l:0-100) en chaîne '#rrggbb'."""
s /= 100.0
l /= 100.0
c = (1.0 - abs(2.0 * l - 1.0)) * s
x = c * (1.0 - abs((h / 60.0) % 2.0 - 1.0))
m = l - c / 2.0
if h < 60: r, g, b = c, x, 0.0
elif h < 120: r, g, b = x, c, 0.0
elif h < 180: r, g, b = 0.0, c, x
elif h < 240: r, g, b = 0.0, x, c
elif h < 300: r, g, b = x, 0.0, c
else: r, g, b = c, 0.0, x
ri, gi, bi = int((r + m) * 255), int((g + m) * 255), int((b + m) * 255)
return f"#{ri:02x}{gi:02x}{bi:02x}"
def cluster_color(cluster_idx: int) -> 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:
def risk_to_gradient_color(risk: float) -> str:
"""
Repousse les centroïdes trop proches par répulsion itérative (spring repulsion).
Chaque point suit le déplacement de son centroïde.
Mappe un score de non-humanité [0,1] sur un dégradé HSL continu multi-stop.
Paramètres
----------
min_dist : distance minimale souhaitée entre centroïdes (espace [0,1]).
Augmenter pour plus d'éclatement.
n_iter : nombre d'itérations de la physique de répulsion.
risk = 0.0 → hue 220° (bleu froid — trafic humain légitime)
risk = 0.25 → hue 165° (cyan-vert — léger signal 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é)
La saturation monte légèrement avec le risque pour accentuer la lisibilité.
"""
rng = np.random.default_rng(0)
centroids = np.zeros((k, 2))
counts = np.zeros(k, dtype=int)
for j in range(k):
mask = labels == j
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_
r = float(np.clip(risk, 0.0, 1.0))
hue = (1.0 - r) * 220.0 # 220° → 0°
saturation = 70.0 + r * 20.0 # 70% → 90%
lightness = 58.0 - r * 10.0 # 58% → 48% (plus sombre = plus alarmant)
return _hsl_to_hex(hue, saturation, lightness)

View File

@ -519,20 +519,19 @@ export default function ClusteringView() {
style={{ width: '100%', height: '100%' }}
controller={true}
>
{/* Légende overlay */}
<div style={{ position: 'absolute', bottom: 16, left: 16, pointerEvents: 'all' }}>
<div className="bg-black/70 rounded-lg p-2 text-xs flex flex-col gap-1">
<div className="text-white/50 text-[10px] uppercase tracking-wide mb-1">Clusters</div>
{data?.nodes?.slice(0, 6).map((n) => (
<div key={n.id} className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ background: n.color }} />
<span className="text-white/70 truncate max-w-[120px]">{n.label}</span>
{/* Légende overlay — gradient non-humanité */}
<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.5">
<div className="text-white/50 text-[10px] uppercase tracking-wide">Non-humanité</div>
{/* Barre de dégradé bleu → rouge */}
<div className="relative w-28 h-3 rounded-full overflow-hidden"
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%)' }}>
</div>
))}
{(data?.nodes?.length ?? 0) > 6 && (
<div className="text-white/30 text-[10px]">+{(data?.nodes?.length ?? 0) - 6} autres</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="flex justify-between w-28 text-[9px] text-white/50">
<span>Humain</span>
<span>Bot</span>
</div>
<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
</div>
</div>