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
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 :
.spacybinary (viaspacy 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_lgouen_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/portne 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 :
