NOPE LinkedIn

Catégories:
Blog

Fine-tuner un NER sur des logs serveur : méthodologie et choix du framework

Fine-tuner un NER sur des logs serveur : méthodologie et choix du framework image

Rubrique: Blog Tag: NLP Tag: NER Tag: spaCy Tag: Logs Tag: Fine-tuning Tag: Active learning Tag: MLOps Tag: Cybersécurité Tag: Anonymisation

Fine-tuner un NER sur des logs serveur : méthodologie et choix du framework

Un log serveur n’est pas du texte. C’est une séquence semi-structurée, produite par un démon, dans un format qui varie selon la version du logiciel, la configuration, et parfois l’humeur de l’admin qui a écrit le rsyslog.conf. Les modèles NLP généralistes — entraînés sur des corpus de presse et de Wikipedia — sont largement aveugles à ce type de données.

Pourtant, les besoins sont réels : anonymiser les logs avant de les transmettre à un éditeur, détecter des entités sensibles pour un pipeline SIEM, extraire automatiquement des IOC. Ce sont des tâches NER. Et elles nécessitent un modèle entraîné sur des données de logs réels.

Voici la méthodologie complète pour le faire proprement.


Choisir le bon framework

La première question n’est pas “quel modèle ?”, c’est “quel niveau d’abstraction ?”.

Framework Pour Contre Verdict logs
spaCy Pipeline modulaire, NER custom, production-ready, règles + ML hybride Moins flexible que transformers Recommandé pour NER structuré
Flair Contextual embeddings, excellente NER séquentielle Moins de tooling ops Bon pour entités ambiguës
HuggingFace (BERT/RoBERTa) SOTA sur benchmarks, transfert fort Coûteux, overkill si format stable Pour logs très variés ou en langage naturel
Regex seul Rapide, déterministe Fragile, pas de généralisation Insuffisant seul

Recommandation : spaCy v3+, avec en_core_web_lg comme base ou un blank model si les logs sont trop éloignés du langage naturel.

Les raisons sont pragmatiques. Les logs sont semi-structurés — les entités (IP, hostname, user, path, port) apparaissent dans des positions prévisibles, avec des formats largement déterministes. spaCy supporte l’hybride EntityRuler + NER : les patterns regex couvrent les cas simples, le modèle ML généralise sur les cas ambigus. Et le pipeline est conçu pour la production — pas pour les notebooks de recherche.


La méthodologie en cinq phases

Collecte → Annotation → Split → Entraînement → Évaluation → Itération

Phase 1 — Collecte & exploration

Avant d’annoter quoi que ce soit, comprendre les données :

  • Collecter au minimum 500-1000 lignes par type de log
  • Identifier les entités cibles : IP, hostname, user, path, action, port, proto, status…
  • Étudier la distribution : les entités apparaissent-elles toujours à la même position ?

Cette dernière question est importante. Si les logs sont positionnels (format fixe), un parseur de champs suffira pour les entités structurées. Le NER n’est vraiment utile que pour les entités contextuelles — celles qui nécessitent de comprendre le contexte pour être correctement classifiées.

Phase 2 — Annotation

  • Outils recommandés : Label Studio (gratuit, self-hosted), Prodigy (payant, spaCy natif), ou doccano
  • Format cible : .spacy binary (via spacy convert) ou JSONL
  • Règle d’or : 2 annotateurs minimum, avec calcul du coefficient inter-annotateur (Cohen’s kappa > 0.8)

Un kappa < 0.8 signifie que les annotateurs ne s’accordent pas sur la définition des entités — le problème est dans le guide d’annotation, pas dans les données.

Phase 3 — Split

Train : 70%  |  Dev : 15%  |  Test : 15%

Stratifier par type de log si le corpus est multi-source. Ne pas mélanger aléatoirement des logs nginx et des logs auditd dans le même split — la distribution serait baisée.

Phase 4 — Entraînement

python -m spacy init config config.cfg --lang en --pipeline ner
python -m spacy train config.cfg --output ./models \
    --paths.train data/train.spacy \
    --paths.dev data/dev.spacy

Surveiller le F1 par entité sur le dev set — pas seulement le F1 global. Un F1 global de 0.85 peut cacher un F1 de 0.40 sur l’entité USER, qui est précisément celle qui compte.

Phase 5 — Évaluation

  • F1 / Precision / Recall par classe d’entité
  • Tester sur des logs hors distribution : nouveaux serveurs, nouvelles versions du logiciel
  • Vérifier les faux positifs sur entités sensibles — critique pour l’anonymisation : un faux positif masque une information légitime, un faux négatif laisse passer une donnée sensible

Quand un nouveau format de logs arrive

Avant de lancer l’annotation ou l’entraînement, il faut poser les bonnes questions. Cette checklist structure le dialogue avec la personne qui fournit les logs.

A. Comprendre le format

  • Quel service ou démon génère ces logs ? (syslog, nginx, fail2ban, auditd, OPNsense filter…)
  • Quel est le format exact ? (RFC 5424, JSON structuré, CLF, format propriétaire…)
  • Y a-t-il une documentation officielle du format ?
  • Les champs sont-ils positionnels ou nommés ?
  • Y a-t-il des sous-formats selon le niveau de verbosité ou la version du logiciel ?

B. Inventorier les entités

  • Quelles entités sensibles sont présentes ? IPs, usernames, mots de passe en clair ?
  • Les entités ont-elles un format canonique fixe ou variable ? (IPv4 vs IPv6, FQDN vs IP brute)
  • Y a-t-il des entités composées ? (user@domain, proto/port, 192.168.1.1:443)
  • Y a-t-il des valeurs nulles ou placeholders ? (-, N/A, unknown, (null))

C. Évaluer le volume et la distribution

  • Combien de lignes par jour en production ?
  • Quel est le ratio attendu normal / anomalie ?
  • Y a-t-il des pics saisonniers ou événementiels qui changent la distribution ?

D. Qualifier les données fournies

  • Les logs sont-ils déjà partiellement anonymisés ? (risque : entités tronquées ou remplacées par des tokens qui brouillent l’annotation)
  • Sont-ils issus de production réelle ou d’un environnement de test ?
  • Y a-t-il des logs avant et après un incident pour tester la détection d’anomalie ?
  • Combien d’exemples la personne peut-elle fournir pour l’annotation ?

E. Définir les critères de succès

  • Quel F1 minimum est acceptable par entité ?
  • Quelles entités ont priorité absolue — ne doivent jamais être manquées ?
  • Quel est le coût comparé d’un faux positif vs d’un faux négatif pour ce use case ?

Ré-entraîner ou adapter ?

Face à un nouveau format, la décision dépend de ce qui change :

Nouveau format de logs ?
   ├── Entités déjà connues + format structuré similaire
   │     → EntityRuler (regex/patterns) → test F1 → si < seuil : fine-tune
   ├── Entités nouvelles OU format très différent
   │     → Annoter 300+ exemples → fine-tune sur le modèle existant
   └── Langage applicatif très spécifique (logs métier custom)
         → Entraîner un blank model spaCy dédié

L’EntityRuler seul couvre ~80% des cas pour les entités à syntaxe fixe (IP, CVE, hostname avec pattern connu). Le fine-tuning n’apporte de la valeur que pour les entités contextuelles — distinguer un corp-monitoring (community SNMP sensible) d’un label générique dans un message d’information.


Gros volumes : 80 Go de logs

À cette échelle, annoter et entraîner sur l’intégralité du corpus n’est ni nécessaire ni réaliste. La stratégie est d’échantillonner intelligemment.

Échantillonnage représentatif

Ne pas prendre les 300 premières lignes — elles sont souvent homogènes et non représentatives de la distribution réelle.

# Échantillon aléatoire sur l'ensemble du fichier
shuf -n 5000 access.log > sample.log

# Ou distribution uniforme (1 ligne sur N)
awk 'NR % 1000 == 0' huge.log > sample.log

Puis clusteriser l’échantillon avec Drain3 — un algorithme de log parsing qui extrait les templates de lignes :

  • Annoter en priorité les clusters les plus fréquents
  • S’assurer que les clusters rares (anomalies, erreurs) sont représentés dans le train set

Si le corpus ne contient que 150 templates distincts, l’effort d’annotation peut être très ciblé : annoter quelques exemples par template couvre l’essentiel de la variabilité.

Dimensionnement

Volume logs/jour Stratégie annotation Taille train set cible
< 1 Go Annotation manuelle complète possible 1 000 - 5 000 exemples
1 - 20 Go Échantillon stratifié + Drain3 clustering 3 000 - 10 000 exemples
> 20 Go Drain3 + annotation par template + active learning 5 000 - 20 000 exemples

Entraînement en streaming

spaCy supporte l’entraînement via des générateurs Python — pas besoin de charger 80 Go en RAM :

def stream_logs(path):
    with open(path) as f:
        for line in f:
            yield nlp.make_doc(line.strip())

Pour l’annotation et la conversion, traiter par chunks de 10 à 50k lignes.

Inférence à grande échelle

Deux approches pour traiter 80 Go en production :

# Découper en chunks et paralléliser avec GNU parallel
split -l 500000 huge.log chunk_
parallel "python anonymize.py {}" ::: chunk_*

Ou directement via spaCy avec multiprocessing natif :

for doc in nlp.pipe(lines, batch_size=256, n_process=4):
    process(doc)

Automatiser l’annotation

L’annotation manuelle est le goulot d’étranglement de tout projet NLP. Sur des logs serveur, elle peut être largement automatisée — les entités ont des formats plus prévisibles que dans du texte naturel.

Approche 1 — Auto-annotation par regex

Les entités à syntaxe fixe peuvent être annotées automatiquement avec une précision proche de 100% :

import re, json

PATTERNS = {
    "IP":        r'\b(?:\d{1,3}\.){3}\d{1,3}(?:/\d{1,2})?\b',
    "CVE":       r'CVE-\d{4}-\d{4,7}',
    "MAC":       r'\b(?:[0-9A-Fa-f]{2}[:\-]){5}[0-9A-Fa-f]{2}\b',
    "PORT":      r'\bport\s+(\d{1,5})\b',
    "TIMESTAMP": r'\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}',
}

def auto_annotate(line):
    entities = []
    for label, pattern in PATTERNS.items():
        for m in re.finditer(pattern, line):
            entities.append((m.start(), m.end(), label))
    # Dédupliquer les spans qui se chevauchent
    entities.sort(key=lambda x: x[0])
    return {"text": line, "entities": entities}

En logs de sécurité, cette approche couvre 60 à 70% des entités cibles sans intervention humaine.

Approche 2 — LLM comme annotateur (silver labels)

Un LLM local (Phi-3.5-mini, Mistral-7B) ou via API peut annoter des batches de logs avec un prompt structuré. La qualité est inférieure à l’annotation humaine, mais suffisante pour bootstrapper un modèle :

import anthropic, json

client = anthropic.Anthropic()

PROMPT = """Annote les entités suivantes dans cette ligne de log.
Entités à détecter : IP, HOSTNAME, USER, PATH, PORT, PROTO, CVE, RULE.

Réponds UNIQUEMENT en JSON : {"entities": [{"text": "...", "label": "...", "start": N, "end": N}]}

Log : {log_line}"""

def llm_annotate(line):
    response = client.messages.create(
        model="claude-haiku-4-5-20251001",
        max_tokens=512,
        messages=[{"role": "user", "content": PROMPT.format(log_line=line)}]
    )
    return json.loads(response.content[0].text)

Pour réduire les coûts : utiliser un modèle rapide (Haiku, Phi-3.5-mini) sur les cas simples, réserver le modèle puissant aux cas ambigus détectés par score de confiance.

Approche 3 — Weak supervision avec Snorkel

Snorkel permet de définir des labeling functions — heuristiques, regex, règles métier — qui votent sur chaque span candidat. Le modèle de données réconcilie les votes en probabilités sans annotation humaine :

from snorkel.labeling import labeling_function, PandasLFApplier
from snorkel.labeling.model import LabelModel

IP_PATTERN = re.compile(r'\b(?:\d{1,3}\.){3}\d{1,3}\b')
SVC_PATTERN = re.compile(r'\bsvc_\w+')

@labeling_function()
def lf_ip_address(x):
    return 1 if IP_PATTERN.search(x.text) else -1  # 1=IP, -1=abstain

@labeling_function()
def lf_service_account(x):
    return 1 if SVC_PATTERN.search(x.text) else -1

applier = PandasLFApplier(lfs=[lf_ip_address, lf_service_account])
L_train = applier.apply(df_train)

label_model = LabelModel(cardinality=2)
label_model.fit(L_train)
probs = label_model.predict_proba(L_train)

L’avantage : les labeling functions sont maintenables et versionnables comme du code — quand le format de log change, on met à jour la fonction, pas le dataset d’annotation.

Note open source : la bibliothèque snorkel (pip install snorkel, Apache 2.0) est distincte du produit commercial Snorkel Flow. Le code ci-dessus n’utilise que la lib open source. Son développement s’est ralenti depuis 2023 — pour des projets nouveaux, Argilla (anciennement Rubrix) offre une alternative activement maintenue, avec une interface web intégrée pour la revue humaine et le weak supervision.

Approche 4 — Self-training bootstrap

Combiner regex (haute précision) et modèle ML en boucle :

Regex → annote les cas certains (conf. > 0.95)
Entraîner spaCy v0 sur ces silver labels
Prédire sur le reste du corpus
Garder uniquement les prédictions à haute confiance → train set v1
Ré-entraîner v1 → itérer
Soumettre à l'humain uniquement les cas restés incertains

Stratégie recommandée selon le volume

Volume Stratégie
< 1 Go Regex auto-annotation → revue humaine des erreurs
1 - 20 Go Regex (entités fixes) + LLM (entités contextuelles) → validation humaine sur échantillon
> 20 Go Drain3 clustering + Snorkel → self-training → active learning sur les 5-10% restants

La revue humaine reste nécessaire — mais elle se concentre sur la correction d’erreurs plutôt que sur l’annotation from scratch, ce qui est 3 à 5 fois plus rapide.


Active learning : annoter moins pour apprendre autant

L’active learning réduit l’effort d’annotation de 60 à 70% sur de gros corpus. Le principe : ne soumettre à un annotateur humain que les exemples sur lesquels le modèle est le moins confiant.

Le cycle

500 annotations manuelles
   Entraîner v0
   Prédire sur 50k lignes → scorer la confiance
   Extraire les 300-500 cas les plus incertains → annoter
   Entraîner v1 (train = v0 + nouveaux exemples)
   Répéter jusqu'à stabilisation du F1

Implémentation

Étape 1 — Annoter manuellement ~500 lignes, convertir et entraîner :

python -m spacy convert annotations.jsonl ./data --converter ner
python -m spacy train config.cfg --output ./models/v0 \
    --paths.train data/train.spacy --paths.dev data/dev.spacy

Étape 2 — Scorer la confiance du modèle sur le corpus non annoté :

import spacy, json

nlp = spacy.load("./models/v0/model-best")

results = []
with open("sample.log") as f:
    lines = [l.strip() for l in f if l.strip()]

for doc in nlp.pipe(lines, batch_size=256):
    # Heuristique : couverture des entités détectées vs longueur du document
    ent_coverage = sum(len(e) for e in doc.ents) / max(len(doc), 1)
    results.append({
        "text": doc.text,
        "confidence": ent_coverage,
        "ents": [(e.text, e.label_) for e in doc.ents]
    })

# Cas les plus incertains en premier
uncertain = sorted(results, key=lambda x: x["confidence"])[:500]

with open("to_annotate.jsonl", "w") as f:
    for r in uncertain:
        f.write(json.dumps(r, ensure_ascii=False) + "\n")

Étape 3 — Importer to_annotate.jsonl dans Label Studio, annoter, exporter.

Étape 4 — Fusionner et ré-entraîner :

python -m spacy merge data/train.spacy data/new_annotations.spacy data/train_v2.spacy
python -m spacy train config.cfg --output ./models/v1 \
    --paths.train data/train_v2.spacy --paths.dev data/dev.spacy

Critère d’arrêt : delta F1 < 0.5% entre deux itérations consécutives, ou absence d’erreurs modèle dans les nouveaux exemples soumis.


Automatiser les autres phases du pipeline

L’annotation n’est pas la seule phase automatisable. Voici ce qui peut être délégué à du code dans chacune des autres étapes.

Phase 1 — Collecte & exploration

Détection automatique du format : plutôt que de demander à un humain d’identifier le format d’un nouveau fichier de logs, un script peut le déduire :

import re
from drain3 import TemplateMiner

FORMAT_SIGNATURES = {
    "syslog_rfc5424": re.compile(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'),
    "nginx_clf":      re.compile(r'^\d+\.\d+\.\d+\.\d+ - - \['),
    "opnsense_filter":re.compile(r'filterlog:'),
    "json":           re.compile(r'^\s*\{'),
}

def detect_format(path, sample=500):
    counts = {k: 0 for k in FORMAT_SIGNATURES}
    with open(path) as f:
        for i, line in enumerate(f):
            if i >= sample:
                break
            for name, pattern in FORMAT_SIGNATURES.items():
                if pattern.search(line):
                    counts[name] += 1
    return max(counts, key=counts.get)

Inventaire automatique des entités candidates : scanner l’échantillon avec les regex de base pour produire une carte de fréquence avant toute décision d’annotation.

Clustering Drain3 automatique : extraire les templates de lignes sans intervention humaine, trier par fréquence, identifier les clusters rares.

miner = TemplateMiner()
templates = {}
with open("sample.log") as f:
    for line in f:
        result = miner.add_log_message(line.strip())
        tid = result["cluster_id"]
        templates[tid] = templates.get(tid, 0) + 1

# Templates triés par fréquence décroissante
for tid, count in sorted(templates.items(), key=lambda x: -x[1]):
    print(f"[{count:6d} lignes] {miner.drain.id_to_cluster[tid].get_template()}")

Phase 3 — Split

Entièrement automatisable. spaCy et scikit-learn fournissent les outils :

from sklearn.model_selection import train_test_split

# Stratifier par type de log pour garantir la représentation de chaque format
train, temp = train_test_split(data, test_size=0.30, stratify=[d["log_type"] for d in data])
dev, test   = train_test_split(temp, test_size=0.50, stratify=[d["log_type"] for d in temp])

Vérification automatique de l’équilibre des classes après split — alerter si une entité est absente du dev ou test set.

def check_split_coverage(train, dev, test):
    train_labels = {e[2] for d in train for e in d["entities"]}
    dev_labels   = {e[2] for d in dev   for e in d["entities"]}
    missing = train_labels - dev_labels
    if missing:
        print(f"ALERTE : entités absentes du dev set : {missing}")

Phase 4 — Entraînement

Tracking automatique des expériences avec MLflow ou Weights & Biases — chaque run logge automatiquement les hyperparamètres, la courbe de loss, et les métriques par entité :

# spaCy supporte W&B nativement via le logger
pip install spacy-loggers
# Dans config.cfg
[training.logger]
@loggers = "spacy.WandbLogger.v3"
project_name = "ner-logs"

Hyperparameter search automatique avec Optuna :

import optuna, subprocess, json

def objective(trial):
    lr    = trial.suggest_float("lr", 1e-4, 1e-2, log=True)
    batch = trial.suggest_categorical("batch_size", [128, 256, 512])
    # Générer config.cfg avec ces paramètres, lancer training, lire F1
    result = subprocess.run([...], capture_output=True)
    return parse_f1(result.stdout)

study = optuna.create_study(direction="maximize")
study.optimize(objective, n_trials=20)

Early stopping : spaCy l’implémente nativement via training.patience dans la config — arrêt automatique si le F1 ne progresse plus sur N évaluations consécutives.

Phase 5 — Évaluation

Rapport automatique par entité à chaque run :

python -m spacy evaluate ./models/v1/model-best data/test.spacy \
    --output metrics/v1.json

Détection automatique de régression entre deux versions :

import json

def compare_runs(prev_path, curr_path, threshold=0.02):
    prev = json.load(open(prev_path))["ents_per_type"]
    curr = json.load(open(curr_path))["ents_per_type"]
    for label in prev:
        delta = curr.get(label, {}).get("f", 0) - prev[label]["f"]
        if delta < -threshold:
            print(f"REGRESSION {label}: F1 {prev[label]['f']:.3f}{curr.get(label, {}).get('f', 0):.3f}")

Monitoring de dérive en production : surveiller le taux d’entités détectées par ligne sur une fenêtre glissante — une chute significative signale un changement de format ou un nouveau type de log non couvert par le modèle.

from collections import deque
import statistics

window = deque(maxlen=10000)

def monitor(doc):
    ent_rate = len(doc.ents) / max(len(doc), 1)
    window.append(ent_rate)
    if len(window) == window.maxlen:
        mean = statistics.mean(window)
        if mean < BASELINE_RATE * 0.7:  # chute > 30%
            alert(f"Dérive détectée : taux entités = {mean:.3f} (baseline = {BASELINE_RATE:.3f})")

Vue d’ensemble : ce qui reste humain

Phase Automatisable Reste humain
Collecte Détection format, clustering, inventaire entités Décision des entités cibles, définition des labels
Annotation Regex, LLM, Snorkel, self-training Revue des cas incertains, arbitrage inter-annotateurs
Split Entièrement automatisable Validation que le split a du sens métier
Entraînement Tracking, hyperparameter search, early stopping Choix de l’architecture, validation des résultats
Évaluation Rapport par entité, détection de régression, monitoring prod Interprétation des erreurs, décision de déploiement

Ce qui reste irréductiblement humain : définir ce qui compte (quelles entités, quel seuil, quel trade-off faux positif / faux négatif) et interpréter les erreurs pour corriger le pipeline en conséquence.


Points ouverts

Ces questions restent à trancher selon le contexte de chaque projet.

NLP & modèle

  • Langue du modèle de base : fr_core_news_lg ou en_core_web_lg ? Pour les logs OPNsense, l’anglais technique domine mais les messages système peuvent être mixtes.
  • Blank model vs pretrained : à tester sur un échantillon pilote — les logs peuvent être suffisamment éloignés du langage naturel pour qu’un blank model surpasse un modèle général.
  • Tokenisation custom : les patterns 192.168.1.1:443, user@domain, proto/port ne sont pas gérés par le tokenizer par défaut de spaCy.

Données & annotation

  • NER token ou classification ligne ? Pour l’anonymisation, NER token est obligatoire. Pour la détection d’anomalie, la classification suffit.
  • Entités composées et imbriquées : spaCy NER ne supporte pas nativement les entités imbriquées — utiliser la spans API (v3) si nécessaire.
  • Volume réel de templates Drain3 : si < 200 templates, l’annotation peut être très ciblée.

Pipeline & intégration

  • Fréquence de ré-entraînement : le modèle est-il stable une fois validé, ou doit-il évoluer avec les nouvelles versions des services ?
  • Seuil de confiance pour l’alerte : en dessous de quel score NER signaler une entité comme potentiellement manquée ?
  • Monitoring en prod : surveiller le taux d’entités détectées par ligne et alerter sur une dérive significative.

Ressources :