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

@ -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)