Documentation technique complète — problème, code ligne par ligne, analyse des plots et interprétation scientifique des résultats.
Avant tout code et toute technique, il faut comprendre le problème réel qu'on cherche à résoudre. Cette section explique le contexte, l'enjeu scientifique et pourquoi le Machine Learning est la bonne approche.
La réalité virtuelle est aujourd'hui déployée dans des domaines critiques : thérapie comportementale, rééducation post-AVC, formation médicale, éducation et industrie. Pourtant, une proportion importante des utilisateurs développe des symptômes invalidants dès les premières minutes d'immersion.
Technologie immersive à fort potentiel thérapeutique et éducatif. Des millions d'utilisateurs y sont exposés sans évaluation préalable de leur tolérance ou vulnérabilité.
Vertiges (80%), désorientation (88%), nausées (30%), maux de tête (30%). Dans les cas extrêmes : panique et chutes physiques observées lors de scènes à haute hauteur.
Les systèmes VR n'intègrent aucun mécanisme d'adaptation au profil de l'utilisateur. Aucun standard clinique n'existe pour évaluer le risque avant une session immersive.
Le Machine Learning peut apprendre les patterns complexes entre profil utilisateur et symptômes, pour prédire le niveau de cybersickness et identifier les profils à risque automatiquement.
Données collectées lors de la Journée de la Santé Citée & Porte de Versailles, ~100 participants en session VR réelle :
| # | Objectif | Technique | Type d'apprentissage | But concret |
|---|---|---|---|---|
| 1 | Prédire le niveau de cybersickness | Classification | Supervisé | Faible / Modéré / Sévère |
| 2 | Estimer le score SSQ exact | Régression | Supervisé | Un nombre continu ex: 6.75 |
| 3 | Visualiser les données 13D en 2D | PCA | Non supervisé | Comprendre la structure des données |
| 4 | Découvrir des profils utilisateurs | K-Means | Non supervisé | Groupes naturels sans labels |
Les ~30 questionnaires papier collectés sur le terrain sont insuffisants pour entraîner un modèle ML fiable (minimum recommandé : 150–300 exemples). On génère un dataset synthétique dont les probabilités reproduisent fidèlement les taux observés sur le terrain.
import numpy as np
import pandas as pd
np.random.seed(42) # Graine fixée : même résultat à chaque exécution (reproductibilité)
N = 300 # 300 participants simulés
# Âge : on crée 4 tranches séparées pour refléter la vraie distribution terrain
age = np.concatenate([
np.random.randint(10, 18, 40), # 40 enfants / ados
np.random.randint(18, 35, 130), # 130 jeunes adultes (majorité sur le terrain)
np.random.randint(35, 55, 90), # 90 adultes
np.random.randint(55, 80, 40), # 40 seniors
])
np.random.shuffle(age) # Mélange pour éviter tout ordre artificiel dans le CSV
# Genre : 52/48 ratio observé sur le terrain
genre = np.random.choice(['M', 'F'], N, p=[0.52, 0.48])Sans seed, les nombres aléatoires changent à chaque exécution → résultats différents à chaque run. Avec seed(42), on fixe la séquence aléatoire. Le 42 est une convention (référence culturelle à The Hitchhiker's Guide to the Galaxy), mais n'importe quelle valeur fonctionne. Essentiel en recherche pour que d'autres puissent reproduire exactement tes résultats.
# Fonction clé : génère un score de symptôme (0–10) basé sur les taux observés
def gen_score(prob_base, sensible_age=True):
scores = []
for i in range(N):
p = prob_base
if sensible_age and (age[i] < 15 or age[i] > 60):
p = min(p + 0.15, 1.0) # Enfants et seniors : +15% de probabilité (vulnérabilité connue)
if duree[i] > 10:
p = min(p + 0.10, 1.0) # Longue exposition : +10% (fatigue cumulative)
scores.append(np.random.binomial(10, p)) # Score entre 0 et 10
return np.array(scores)
# Application avec les taux RÉELLEMENT observés sur le terrain
vertige_score = gen_score(0.80) # 80/100 personnes sur le terrain
desorientation_score = gen_score(0.88) # 88/100 personnes sur le terrain
nausee_score = gen_score(0.30) # 30/100 personnes sur le terrain
sueurs_score = gen_score(0.05, sensible_age=False) # Quasi-absent
# Score SSQ global : pondération scientifique Kennedy (1993)
ssq_score = (
vertige_score * 0.25 + # Symptôme dominant
desorientation_score * 0.25 + # Symptôme dominant
nausee_score * 0.20 + # Symptôme important
maux_tete_score * 0.10 +
fatigue_oculaire_score * 0.10 +
sueurs_score * 0.05 +
peur_panique * 5.00 # Événement critique = malus fort
).round(2)binomial(n=10, p) simule 10 "tirages" indépendants, chacun avec probabilité p de succès. Le résultat est le nombre de succès : un entier entre 0 et 10. Avec p=0.80 (vertige), on obtiendra en moyenne 8/10. Cette distribution est réaliste car les symptômes ont une intensité variable autour d'une moyenne — certains ressentent peu, d'autres beaucoup, même avec la même probabilité de base.
Avant tout modèle ML, on explore les données visuellement. L'EDA permet de comprendre les distributions, détecter les anomalies, identifier les corrélations et formuler des hypothèses. Un expert ML passe 60 à 70% de son temps ici.
Chaque subplot montre la distribution d'un symptôme (0–10). Les barres bleues = histogramme (combien de participants ont chaque score). La courbe lissée = KDE (Kernel Density Estimation), une version continue de l'histogramme. La ligne rouge pointillée = moyenne du symptôme.
Un histogramme étalé vers la droite = symptôme fréquent et intense. Concentré à gauche = rare ou faible.
Chaque cellule montre la corrélation de Pearson entre deux variables, de -1 à +1. Rouge foncé = forte corrélation positive (les deux montent ensemble). Bleu = corrélation négative. Blanc = aucune relation linéaire. On masque le triangle supérieur car il est identique (symétrie).
La diagonale vaudrait 1.0 (chaque variable est corrélée à elle-même) — on l'ignore.
Boxplot (gauche) : La boîte centrale = 50% des participants (Q1 à Q3). La ligne centrale = médiane. Les moustaches = valeurs min/max hors outliers. Les points au-delà = valeurs aberrantes (outliers).
Violinplot (droite) : Combine boxplot + densité de distribution. Plus la forme est large à une hauteur, plus il y a de participants avec ce score.
Les algorithmes ML sont des formules mathématiques. Ils ne comprennent que des nombres normalisés. Cette étape transforme les données brutes en un format exploitable — c'est le fondement de tout pipeline ML.
from sklearn.preprocessing import LabelEncoder
le_genre = LabelEncoder() # Crée un encodeur vide
le_casque = LabelEncoder()
# fit_transform : apprend les catégories (fit) ET encode (transform) en une étape
df['genre_enc'] = le_genre.fit_transform(df['genre'])
# 'F' → 0, 'M' → 1 (ordre alphabétique)
df['casque_enc'] = le_casque.fit_transform(df['type_casque'])
# 'HTC Vive'→0, 'Meta Quest 2'→1, 'Meta Quest 3'→2, 'PlayStation VR'→3, 'Valve Index'→4Un algorithme calcule des distances et des produits. Il ne peut pas faire "Meta Quest 2" × 0.5. LabelEncoder attribue un entier par catégorie. Limite importante : l'encodage crée un ordre artificiel (Valve Index = 4 > Meta Quest 2 = 1). Pour de vrais projets de production, on préférerait OneHotEncoder qui crée une colonne binaire par catégorie. Ici, c'est acceptable car les modèles ensemblistes (Random Forest, Gradient Boosting) sont robustes à cet artefact.
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
# Formule : X_new = (X - μ) / σ → résultat : μ=0, σ=1 pour chaque variable
# Avant : age=[10–80], sueurs=[0–10] → échelles incompatibles
# Après : age≈[-1.4 à +2.7], sueurs≈[-0.6 à +4.1] → même ordre de grandeurfit() le scaler sur X_train uniquement, puis transform() sur train ET test séparément. Si on fit sur tout le dataset, la moyenne et l'écart-type calculés "voient" des données du test set, donnant un avantage artificiel au modèle lors de l'évaluation — c'est comme donner les réponses d'un examen à l'avance.from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
X_scaled, y_class,
test_size=0.2, # 20% → 60 participants pour tester le modèle
random_state=42, # Même split à chaque run
stratify=y_class # Garantit les mêmes proportions dans train et test
)
# Train: 240 participants | Test: 60 participants | Features: 13Sans stratification, le split aléatoire pourrait mettre 90% des "sévère" dans le train set et seulement 10% dans le test — le modèle serait mal évalué sur les cas sévères. stratify=y_class garantit que la proportion faible/modéré/sévère est identique dans train et test, rendant l'évaluation équitable et représentative.
Objectif : à partir des symptômes et du profil d'un participant, prédire automatiquement si son niveau de cybersickness est faible, modéré ou sévère. On compare 5 algorithmes différents.
Trouve une frontière linéaire (hyperplan) entre les classes. Calcule une probabilité par classe via la fonction sigmoïde. Très interprétable : les coefficients indiquent le poids de chaque feature.
🏆 96.7% — MeilleurPrédit en cherchant les 5 participants les plus similaires dans les données d'entraînement et en votant à la majorité. Aucun paramètre appris — la "mémoire" est le dataset entier.
81.7%Cherche la frontière qui maximise la marge entre les classes. Le kernel RBF projette les données dans un espace de dimension supérieure pour créer des frontières non-linéaires.
95.0%Entraîne 100 arbres de décision sur des sous-échantillons aléatoires. Vote majoritaire final. Robuste, peu sensible au surapprentissage et donne la feature importance.
90.0%Construit les arbres en séquence — chaque arbre se concentre sur les erreurs du précédent. Très performant mais plus lent à entraîner et plus sensible aux hyperparamètres.
93.3%from sklearn.metrics import accuracy_score, classification_report
from sklearn.model_selection import cross_val_score
for name, clf in classifiers.items():
# APPRENTISSAGE : le modèle voit (X_train, y_train) et ajuste ses paramètres
clf.fit(X_train, yc_train)
# PRÉDICTION : le modèle applique ce qu'il a appris sur des données jamais vues
y_pred = clf.predict(X_test)
# ACCURACY : % de bonnes prédictions sur le test set
acc = accuracy_score(yc_test, y_pred)
# CROSS-VALIDATION : répète 5 fois le split → score plus fiable et moins biaisé
cv = cross_val_score(clf, X_scaled, y_class, cv=5)
# cv est un tableau de 5 scores → on garde mean ± stdUn seul train/test split peut être "chanceux" ou "malchanceux" selon comment les données tombent. La CV avec cv=5 découpe le dataset en 5 parts égales. Elle entraîne 5 fois : chaque fois une part différente est le test set et les 4 autres sont le train set. Score final = moyenne des 5 → beaucoup plus stable et représentatif de la vraie performance.
Chaque ligne = classe réelle. Chaque colonne = classe prédite. La diagonale = bonnes prédictions. Hors-diagonale = erreurs (confusion entre classes).
Exemple : si la cellule (sévère, modéré) = 2, cela signifie que le modèle a classé 2 participants "réellement sévères" comme "modéré" — une erreur potentiellement importante en contexte clinique.
Le modèle prédit correctement 58 participants sur 60 dans le test set. F1-score modéré = 0.97, F1-score sévère = 0.97. Excellent et cohérent avec l'accuracy globale.
Le Random Forest révèle quelles variables sont les plus déterminantes pour prédire le niveau de cybersickness :
Le graphe central montre que la Logistic Regression surpasse tous les autres modèles, y compris les plus complexes (Random Forest, Gradient Boosting). Cela s'explique par la nature de nos données : la frontière entre faible/modéré/sévère est linéaire dans l'espace des features standardisées. Le score SSQ étant une combinaison linéaire des symptômes, les classes sont séparées par des hyperplans — exactement ce que la Logistic Regression modélise. Les modèles non-linéaires (SVM RBF, Random Forest) introduisent de la complexité inutile et surapprennnent légèrement. Leçon : la simplicité gagne quand le problème est intrinsèquement simple.
Là où la classification prédit une catégorie, la régression prédit un nombre réel continu — ici le score SSQ (ex: 6.75). Même pipeline, objectif différent, métriques différentes.
from sklearn.linear_model import LinearRegression, Ridge, Lasso
from sklearn.metrics import r2_score, mean_squared_error, mean_absolute_error
regressors = {
'Linear Regression': LinearRegression(),
# Y = a1*vertige + a2*nausee + ... + b (combinaison linéaire)
'Ridge (L2)': Ridge(alpha=1.0),
# Linéaire + pénalité L2 = force les coefficients à rester petits
# Évite l'overfitting quand des features sont corrélées
'Lasso (L1)': Lasso(alpha=0.1),
# Pénalité L1 = peut annuler certains coefficients à exactement 0
# → sélection automatique de features
'Random Forest Reg': RandomForestRegressor(n_estimators=100),
# Capture les relations non-linéaires complexes
}
for name, reg in regressors.items():
reg.fit(X_train, yr_train)
y_pred = reg.predict(X_test)
r2 = r2_score(yr_test, y_pred) # % variance expliquée
rmse = np.sqrt(mean_squared_error(yr_test, y_pred)) # Erreur en unité SSQ
mae = mean_absolute_error(yr_test, y_pred) # Erreur absolue moyenneChaque point = un participant du test set. L'axe X = score SSQ réel, l'axe Y = score SSQ prédit par le modèle. La ligne rouge pointillée = prédiction parfaite (prédit = réel).
Si le modèle était parfait, tous les points seraient sur cette diagonale. Plus les points s'en écartent, plus les erreurs sont grandes.
Résidu = valeur réelle − valeur prédite. Un bon modèle a des résidus :
Explication scientifique du R² négatif : Le score SSQ est défini mathématiquement comme une combinaison linéaire des features (vertige×0.25 + désorientation×0.25 + ...). Il reste donc très peu de variance "cachée" et non-expliquée pour qu'un modèle externe l'apprenne. La régression n'apporte pas de valeur ajoutée ici — c'est un résultat attendu et scientifiquement honnête à rapporter.
| Métrique | Formule | Notre résultat | Interprétation |
|---|---|---|---|
| R² | 1 − SS_rés / SS_tot | ≈ −0.02 | Le modèle n'explique pas la variance — attendu car SSQ = f(features) |
| RMSE | √ moy(y−ŷ)² | 1.304 points | Erreur moyenne de ±1.3 points de SSQ sur 60 participants |
| MAE | moy |y−ŷ| | 0.842 points | En moyenne, le modèle se trompe de 0.84 point SSQ |
On a 13 variables. Impossible de les visualiser simultanément. La PCA compresse ces 13 dimensions en quelques composantes qui capturent l'essentiel de l'information — sans aucun label.
from sklearn.decomposition import PCA
# Étape 1 : PCA complète — calcule toutes les composantes
pca_full = PCA() # Sans n_components = garde tout
pca_full.fit(X_scaled)
# explained_variance_ratio_ : tableau du % de variance capté par chaque composante
# ex: [0.12, 0.10, 0.09, 0.08, ...] → PC1 = 12%, PC2 = 10%...
var_ind = pca_full.explained_variance_ratio_
# np.cumsum : somme cumulative → [0.12, 0.22, 0.31, 0.39, ...]
var_cumul = np.cumsum(var_ind)
# Trouver combien de composantes conservent 90% de l'information
n_comp_90 = np.argmax(var_cumul >= 0.90) + 1 # Résultat : 11
# Étape 2 : Projection 2D pour visualisation
pca_2d = PCA(n_components=2) # On force 2 composantes
X_2d = pca_2d.fit_transform(X_scaled) # 300×13 → 300×2Barres bleues = variance individuelle de chaque composante principale. Courbe rouge = variance cumulée. Les lignes pointillées orange (90%) et rouge (95%) indiquent les seuils.
Résultat : 11 composantes sur 13 pour 90% de l'information.
Pas de "coude" très prononcé → les features contribuent de manière relativement équilibrée, sans qu'une ou deux dominent totalement. C'est cohérent avec la heatmap de corrélation qui montrait des corrélations modérées.
PC1 et PC2 ensemble ne capturent que 27.65% de l'information totale — c'est pourquoi la projection 2D montre des groupes peu séparés.
Chaque point = un participant projeté dans l'espace 2D (PC1, PC2). Coloré par niveau réel de cybersickness.
Observation : Les trois couleurs (vert=faible, orange=modéré, rouge=sévère) se mélangent considérablement dans l'espace PC1/PC2. Il n'y a pas de séparation nette et visuelle.
Sans utiliser les labels, K-Means découvre automatiquement 4 groupes naturels dans les données. Résultat : 4 profils d'utilisateurs VR distincts, identifiés sans aucun a priori.
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
# Tester k de 2 à 9 — on ne sait pas a priori combien de groupes existent
for k in range(2, 10):
km = KMeans(n_clusters=k, random_state=42, n_init=10)
km.fit(X_scaled)
# Inertie = somme des distances² entre points et leur centroïde
# Plus k est grand, plus l'inertie est petite (mais risque d'overfitting)
inertias.append(km.inertia_)
# Silhouette : mesure cohérence interne des clusters (-1 à +1)
# +1 = point bien dans son cluster, loin des autres
# 0 = à la frontière, -1 = probablement dans le mauvais cluster
silhouettes.append(silhouette_score(X_scaled, km.labels_))
best_k = 4 # k qui maximise le score silhouette
km_final = KMeans(n_clusters=best_k, random_state=42, n_init=10)
df['cluster'] = km_final.fit_predict(X_scaled)L'axe Y = inertie (dispersion intra-cluster). Elle diminue toujours quand k augmente — plus on crée de groupes, plus ils sont petits et serrés. On cherche le "coude" où les gains deviennent marginaux.
Ici la courbe décroît de façon assez régulière sans coude très marqué → les données n'ont pas de structure en clusters parfaitement distincts. C'est cohérent avec un phénomène continu comme la cybersickness.
Le score silhouette k=4 (0.136) est le plus élevé — c'est le k optimal. Le score est relativement faible car les clusters se chevauchent : les profils de cybersickness sont des gradients continus, pas des groupes discretement séparés.
La projection 2D confirme : les 4 clusters se chevauchent en 2D. Rappel : les clusters sont définis dans les 13 dimensions originales — le chevauchement en 2D ne remet pas en cause leur validité.
Chaque axe représente un symptôme. La valeur sur l'axe = score moyen du cluster pour ce symptôme (0–10). Plus la surface colorée est grande, plus les symptômes sont intenses dans ce groupe.
Comparer les formes des clusters permet d'identifier les profils différentiels — quel groupe souffre de quoi spécifiquement.
Vue d'ensemble de tous les résultats, leur signification scientifique et les conclusions qu'on peut tirer pour la prévention de la cybersickness en VR.
| Technique | Meilleur modèle | Score clé | Conclusion |
|---|---|---|---|
| Classification | Logistic Regression | 96.7% accuracy | La séparation faible/modéré/sévère est linéairement apprise. Modèle fiable. |
| Régression | Ridge (L2) | R² = −0.02 | Normal : SSQ = f(features). Pas de valeur ajoutée attendue ici. |
| PCA | — | 11/13 composantes | 90% info conservée. Légère redondance entre vertige/désorientation. |
| K-Means | k = 4 | 4 profils identifiés | Profils actionnables : seniors, jeunes longue expo, modéré, panique. |
96.7% de précision prouve que les symptômes rapportés permettent de classifier fiablement le niveau de cybersickness. Une application préventive est techniquement faisable.
La feature importance identifie vertige, nausée et désorientation comme les signaux d'alerte les plus discriminants — ces 3 variables suffisent à la plupart des décisions cliniques.
Le clustering découvre des groupes naturels d'utilisateurs avec des profils de symptômes distincts. Ces profils orientent des recommandations concrètes pour les développeurs VR.
Le R² négatif n'est pas un échec — c'est une observation valide : quand la cible est définie par les features, la régression externe n'apporte pas de gain prédictif.