Contrôle Qualité des Séries de Standards en Laboratoire¶
📚 Introduction¶
Dans ce notebook, nous explorons des concepts clés du contrôle qualité appliqué aux mesures répétées de standards analytiques, notamment :
- 🧪 Génération simulée de séries temporelles de mesures standards, intégrant différents types d’anomalies fréquentes en laboratoire (erreurs de transcription, changements de méthode, tendances).
- 🔍 Détection automatique d’anomalies à partir des règles statistiques classiques basées sur les écarts types (±1σ, ±2σ, ±3σ) et leur interprétation.
- 📊 Visualisation interactive permettant d’explorer l’impact des différents paramètres (niveau de bruit, nombre et amplitude des erreurs, changement de méthode) sur la qualité des mesures et la robustesse des contrôles statistiques.
🎯 Objectifs pédagogiques¶
À la fin de cette séance, vous serez capable de :
- ⚠️ Comprendre les sources potentielles d’anomalies dans une série de mesures répétées d’un standard.
- 📏 Appliquer des règles de contrôle statistique pour identifier ces anomalies.
- 📈 Interpréter graphiquement les résultats de la détection d’anomalies.
- 🕹️ Utiliser des widgets interactifs pour simuler différents scénarios et mieux appréhender la variabilité naturelle et les déviations anormales.
🔍 Détection automatique des anomalies (règles de Western Electric)¶
Basée sur la moyenne (μ) et l’écart-type (σ), 4 règles empiriques détectent les signaux d’un processus potentiellement hors de contrôle :
Liste des critères
Un point au-delà de ±3σ
→ Anomalie majeure
Probabilité d’occurrence ≈ 0.27 % — signal fort d’un événement exceptionnel.Deux points consécutifs au-delà de ±2σ, du même côté
ou → Biais temporaire suspecté
Probabilité d’occurrence ≈ 1 % (valeur empirique).Quatre points consécutifs au-delà de ±1σ, du même côté
ou → Dérive progressive
Probabilité d’occurrence ≈ 1 %.Huit points consécutifs du même côté de la moyenne (μ)
ou → Changement systématique
Probabilité d’occurrence ≈ 1 %.
🧠 Ces seuils sont empiriques, choisis pour un bon compromis entre détection d’anomalies et faux positifs, et peuvent différer des calculs théoriques sous hypothèses normale.
Source
import numpy as np
import matplotlib.pyplot as plt
from ipywidgets import (
IntSlider, FloatSlider, Checkbox, Layout, VBox, HTML, interactive_output
)
from IPython.display import display
# Fonction de covariance sphérique
def spherical_covariance_1d(n, range_):
h = np.abs(np.subtract.outer(np.arange(n), np.arange(n)))
cov = np.where(
h <= range_,
1 - 1.5 * h / range_ + 0.5 * (h / range_)**3,
0
)
return cov
# Génération de la série
def generate_standard_series(
n_points=501,
noise_level=1.0,
corr_length=10.0,
trend_slope=0.0,
n_transcription_errors=0,
transcription_error_magnitude=2.0,
method_change=False,
method_change_point=250,
method_change_magnitude=5.0,
error_zone_fraction=0.2
):
t = np.arange(n_points)
base = 50 + trend_slope * (t - round(len(t)/2))
# Bruit corrélé spatialement (sphérique)
cov = spherical_covariance_1d(n_points, corr_length)
L = np.linalg.cholesky(cov + 1e-6 * np.eye(n_points))
z = np.random.normal(0, 1, n_points)
noise = L @ z
noise = noise / np.std(noise) * np.sqrt(noise_level)
series = base + noise
max_index_for_errors = int(n_points * error_zone_fraction)
if n_transcription_errors > 0 and max_index_for_errors > 0:
indices = np.random.choice(max_index_for_errors, size=n_transcription_errors, replace=False)
errors = np.random.choice([-1, 1], size=n_transcription_errors) * transcription_error_magnitude
series[indices] += errors
if method_change:
series[method_change_point:] += method_change_magnitude
return t, series
# Détection d’anomalies
def detect_anomalies(series, mean, std):
n = len(series)
anomalies = {f"Critère {i}": [] for i in range(1, 5)}
for i in range(n):
if abs(series[i] - mean) > 3 * std:
anomalies["Critère 1"].append(i)
for i in range(n - 1):
if (series[i] - mean > 2 * std and series[i+1] - mean > 2 * std) or \
(series[i] - mean < -2 * std and series[i+1] - mean < -2 * std):
anomalies["Critère 2"].extend([i, i+1])
side = np.sign(series - mean)
outside = np.abs(series - mean) > std
count = 0
for i in range(n):
if outside[i] and (i == 0 or side[i] == side[i-1]):
count += 1
else:
count = 1 if outside[i] else 0
if count >= 4:
anomalies["Critère 3"].append(i)
count_8 = 0
for i in range(n):
if i == 0 or (side[i] == side[i-1] and side[i] != 0):
count_8 += 1
else:
count_8 = 1
if count_8 >= 8:
anomalies["Critère 4"].append(i)
return anomalies
# Tracé
def plot_standard_series(
noise_level,
corr_length,
standard_error,
trend_slope,
n_transcription_errors,
error_zone_fraction,
transcription_error_magnitude,
method_change,
method_change_point,
method_change_magnitude,
):
np.random.seed(42)
t, series = generate_standard_series(
noise_level=noise_level,
corr_length=corr_length,
trend_slope=trend_slope,
n_transcription_errors=n_transcription_errors,
transcription_error_magnitude=transcription_error_magnitude,
method_change=method_change,
method_change_point=method_change_point,
method_change_magnitude=method_change_magnitude,
error_zone_fraction=error_zone_fraction,
)
base_line = np.full_like(t, 50.0)
anomalies = detect_anomalies(series, mean=50.0, std=standard_error)
plt.figure(figsize=(12, 5))
plt.plot(t, series, label="Standard mesuré", color='blue', linestyle='', marker='*')
for k, alpha, color in zip([1, 2, 3], [0.3, 0.2, 0.1], ['#ffcc80', '#ffb74d', '#ffa726']):
plt.fill_between(t, base_line - k * standard_error, base_line + k * standard_error,
color=color, alpha=alpha, label=f'±{k}σ')
plt.plot(t, base_line, color='orange', linestyle='--', label="Teneur attendue (50 ppm)")
markers_info = {
"Critère 1": ("red", 80, 'o'),
"Critère 2": ("purple", 60, 's'),
"Critère 3": ("brown", 50, '^'),
"Critère 4": ("green", 40, 'D'),
}
for crit, (color, size, marker) in markers_info.items():
indices = list(set(anomalies[crit]))
plt.scatter(t[indices], series[indices], color=color, label=crit, s=size, marker=marker,
edgecolors='k', zorder=5)
if method_change:
plt.axvline(method_change_point, color="red", linestyle="--", label="Changement méthode")
plt.xlabel("Temps (échantillon)")
plt.ylabel("Teneur standard (ppm)")
plt.title("Série temporelle de standards avec détection d’anomalies (±σ)")
plt.ylim(42, 58)
plt.xlim([0, 500])
plt.legend(loc='center left', bbox_to_anchor=(1, 0.5), fontsize=10)
plt.grid(True)
plt.tight_layout(rect=[0, 0, 0.85, 1])
plt.show()
# ---------- Widgets ----------
desc_width = '220px'
slider_width = '420px'
layout = Layout(width=slider_width)
section_global_error = VBox([
HTML("<b>Erreur globale :</b>"),
FloatSlider(value=1.0, min=0.1, max=5.0, step=0.1, description="Niveau bruit (σ)", style={'description_width': desc_width}, layout=layout),
FloatSlider(value=1.0, min=1, max=10, step=1.0, description="Corrélation (portée)", style={'description_width': desc_width}, layout=layout),
FloatSlider(value=1.0, min=0.5, max=2.0, step=0.1, description="Erreur type (ppm)", style={'description_width': desc_width}, layout=layout),
])
section_bias = VBox([
HTML("<b>Biais :</b>"),
FloatSlider(value=0.0, min=-0.01, max=0.01, step=0.001, description="Tendance (pente)", style={'description_width': desc_width}, layout=layout),
])
section_local_error_transcription = VBox([
HTML("<b>Erreur locale – Transcription :</b>"),
IntSlider(value=0, min=0, max=20, step=1, description="Erreurs transcription", style={'description_width': desc_width}, layout=layout),
FloatSlider(value=0.2, min=0.05, max=0.5, step=0.05, description="Zone erreurs début", style={'description_width': desc_width}, layout=layout),
FloatSlider(value=2.0, min=1.0, max=4.0, step=0.01, description="Amplitude erreur (ppm)", style={'description_width': desc_width}, layout=layout),
])
section_local_error_method = VBox([
HTML("<b>Erreur locale – Changement d’équipement :</b>"),
Checkbox(value=False, description="Changement méthode"),
IntSlider(value=250, min=1, max=499, step=1, description="Point changement", style={'description_width': desc_width}, layout=layout),
FloatSlider(value=0.0, min=-1.0, max=1.0, step=0.1, description="Amplitude changement (ppm)", style={'description_width': desc_width}, layout=layout),
])
all_controls = VBox([
section_global_error,
section_bias,
section_local_error_transcription,
section_local_error_method
])
controls = {
'noise_level': section_global_error.children[1],
'corr_length': section_global_error.children[2],
'standard_error': section_global_error.children[3],
'trend_slope': section_bias.children[1],
'n_transcription_errors': section_local_error_transcription.children[1],
'error_zone_fraction': section_local_error_transcription.children[2],
'transcription_error_magnitude': section_local_error_transcription.children[3],
'method_change': section_local_error_method.children[1],
'method_change_point': section_local_error_method.children[2],
'method_change_magnitude': section_local_error_method.children[3],
}
output = interactive_output(plot_standard_series, controls)
display(all_controls, output)