Exercice 04 — Calculer le KDA par joueur
Partie 3 —
.Transform()+Normalize()+Smooth()(horsDataSeries<T>, temporaire)
Concepts théoriques
- Thématique 03 — Map et transformation
- Map / Select
- Composition de pipelines
- Closures dans les transformations
Contexte
Comparer les 5 joueurs de Team Helvetia sur un même indicateur est complexe : chaque jeu a son propre système de scoring. Le KDA (Kills + Assists) / Deaths est la métrique commune qui permet la comparaison inter-jeux.
Normaliser le KDA permet ensuite de le comparer à des séries d'autres natures (ex. : vision score de Noé vs headshots de Léa).
Concept FP : Map = transformer sans modifier
.Transform() applique une fonction à chaque élément et retourne une nouvelle série. La source n'est jamais modifiée — même principe que .Filter().
[m1, m2, m3] → Transform(f) → [f(m1), f(m2), f(m3)]Étape 1 — Implémenter .Transform(mapper)
Avant de coder : Transform doit changer le type — passer de DataSeries<ValorantMatch> à DataSeries<double>. Quelle méthode LINQ applique une fonction à chaque élément ?
Indice
Select(mapper) applique une fonction à chaque élément et retourne une nouvelle séquence. La méthode doit être générique : Transform<TResult>(Func<T, TResult> mapper).
public DataSeries<TResult> Transform<TResult>(Func<T, TResult> mapper)
{
// appliquer mapper à chaque élément de _data et retourner une nouvelle DataSeries
// ...
}Voir la solution
public DataSeries<TResult> Transform<TResult>(Func<T, TResult> mapper)
=> DataSeries<TResult>.From(_data.Select(dp => new DataPoint<TResult>(dp.Timestamp, mapper(dp.Value))));Calculer le KDA pour Valorant et chaîner avec Filter :
var kdaLea = valorant
.Filter(m => m.Player == "Léa")
.Transform(m => (m.Kills + m.Assists) / (double)(m.Deaths == 0 ? 1 : m.Deaths));
Console.WriteLine(string.Join(", ", kdaLea.Values.Select(v => v.ToString("F2"))));Reproduire pour CS2 (Raphaël, Kiara) et LoL (Noé).
Observation :
Transformchange le type —DataSeries<ValorantMatch>devientDataSeries<double>. La bibliothèque reste générique, le domaine est dansEsportApp.
Le chaînage
Filter(...).Transform(...)est possible uniquement parce que chaque méthode retourne un nouvel objet au lieu de modifier la source. Immutabilité → composition. → Composition de pipelines
Étape 2 — Normalize() — comparer entre jeux
Simplification temporaire — on sort volontairement de
DataSeries<T>.Normalizea besoin de calculermax - min, une soustraction qui n'a de sens que pourdouble. OrDataSeries<T>reste générique surT(il sert aussi pourValorantMatch,Cs2Match...), donc une méthode d'instanceNormalize()dans cette classe ne compilerait pas pour tous lesT. La vraie solution — une méthode d'extension qui ajouteNormalizeuniquement àDataSeries<double>— demande une notion pas encore vue (exercice 06). En attendant,Normalizen'est pas une méthode deDataSeries<T>: c'est une fonctionstaticisolée dans un utilitaire à part (DataSeries/MathHelpers.cs), appelée explicitement —MathHelpers.Normalize(series)et nonseries.Normalize(). C'est une étape intermédiaire assumée, pas la version définitive de la bibliothèque.
Avant de coder : que signifie normaliser une série entre 0 et 1 ? Quelle formule permet de ramener n'importe quelle valeur dans [0, 1] ?
Indice sur la formule
(valeur - min) / (max - min) — le minimum devient 0, le maximum devient 1. Cas particulier : si max == min (toutes les valeurs identiques), retourner 0 pour éviter une division par zéro.
Exemple sur une série de 4 KDA bruts :
| Match | KDA brut | KDA normalisé |
|---|---|---|
| 1 | 1.2 | 0.00 |
| 2 | 1.8 | 0.25 |
| 3 | 3.6 | 1.00 |
| 4 | 2.4 | 0.50 |
min = 1.2, max = 3.6 : (1.8 - 1.2) / (3.6 - 1.2) = 0.25. Chaque série est normalisée indépendamment, ce qui permet ensuite de comparer des joueurs sur des jeux différents (KDA Valorant vs KDA CS2) malgré des échelles brutes différentes.
public static class MathHelpers
{
public static DataSeries<double> Normalize(DataSeries<double> series)
{
var points = series.DataPoints.ToList();
var values = points.Select(dp => dp.Value).ToList();
var min = // ...
var max = // ...
var range = // ...
return DataSeries<double>.From(
points.Select(dp => new DataPoint<double>(dp.Timestamp, /* formule de normalisation */))
);
}
}Voir la solution
public static class MathHelpers
{
public static DataSeries<double> Normalize(DataSeries<double> series)
{
var points = series.DataPoints.ToList();
var values = points.Select(dp => dp.Value).ToList();
var min = values.Min();
var max = values.Max();
var range = max - min;
return DataSeries<double>.From(
points.Select(dp => new DataPoint<double>(dp.Timestamp, range == 0 ? 0.0 : (dp.Value - min) / range))
);
}
}Comparer les KDA normalisés :
var kdaLeaNorm = MathHelpers.Normalize(kdaLea);
var kdaRaphaelNorm = MathHelpers.Normalize(kdaRaphael);
var kdaNoeNorm = MathHelpers.Normalize(kdaNoe);
// Toutes les valeurs sont maintenant dans [0, 1]Étape 3 — .Smooth(windowSize) et la closure
Avant de coder : la moyenne glissante d'indice i avec une fenêtre de taille w utilise les éléments aux indices [i-w+1 .. i]. Comment générer tous les indices avec LINQ ?
Indice sur la structure
Enumerable.Range(0, values.Count) génère tous les indices. Pour chaque indice i, prendre values.Skip(Max(0, i - w + 1)).Take(w) puis .Average(). La variable windowSize capturée par le lambda est une closure — observer ce que ça implique. → Closures dans les transformations
Même simplification temporaire qu'à l'étape 2 :
Smoothsort elle aussi deDataSeries<T>et rejointMathHelpers, appelée explicitement (MathHelpers.Smooth(series, windowSize)plutôt queseries.Smooth(windowSize)), avecseriesà la place de_data.
public static class MathHelpers // suite de l'étape 2
{
public static DataSeries<double> Smooth(DataSeries<double> series, int windowSize)
{
var points = series.DataPoints.ToList();
var values = points.Select(dp => dp.Value).ToList();
return DataSeries<double>.From(
Enumerable.Range(0, values.Count)
.Select(i =>
{
// extraire la fenêtre autour de i et calculer la moyenne
// retourner new DataPoint<double>(points[i].Timestamp, ...)
// ...
})
);
}
}Voir la solution
public static class MathHelpers // suite de l'étape 2
{
public static DataSeries<double> Smooth(DataSeries<double> series, int windowSize)
{
var points = series.DataPoints.ToList();
var values = points.Select(dp => dp.Value).ToList();
return DataSeries<double>.From(
Enumerable.Range(0, values.Count)
.Select(i =>
{
var window = values.Skip(Math.Max(0, i - windowSize + 1)).Take(windowSize);
return new DataPoint<double>(points[i].Timestamp, window.Average());
})
);
}
}Observer la closure :
int window = 3;
var smoothed = MathHelpers.Smooth(kdaLea, window);
window = 10; // Sans effet — window a été copiée à l'appel de Smooth (passage d'argument)Attention à la nuance : une variable capturée par un lambda l'est par référence — sa modification ultérieure serait visible. Ici
windown'est pas capturée : elle est passée en argument àSmooth, donc copiée. C'estwindowSize(le paramètre) que le lambda capture, et il ne change plus. → Closures
Étape 4 — Interface CLI
Ajouter --stat kda|kills|assists pour choisir la transformation à afficher.
Avant de coder : Comment mapper une valeur de flag ("kda", "kills", "assists") à une transformation différente ? Plutôt qu'une chaîne de if/else (ou même un switch), que permet un dictionnaire de fonctions ? Que faire si la valeur passée est inconnue ?
Indice — table de sélecteurs
Comme la table de prédicats de l'exercice 03 : un sélecteur Func<ValorantMatch, double> est une valeur — il peut être stocké dans un Dictionary et choisi à l'exécution. → Fonctions comme valeurs
Voir la solution
string stat = args.Contains("--stat")
? args[Array.IndexOf(args, "--stat") + 1]
: "kda";
// Table de sélecteurs — le flag CLI choisit la fonction de transformation
var selectors = new Dictionary<string, Func<ValorantMatch, double>>
{
["kda"] = m => (m.Kills + m.Assists) / (double)(m.Deaths == 0 ? 1 : m.Deaths),
["kills"] = m => m.Kills,
["assists"] = m => m.Assists,
};
if (!selectors.ContainsKey(stat))
throw new ArgumentException($"Stat inconnue : {stat}");
DataSeries<double> values = valorantSeries.Transform(selectors[stat]);La fonction choisie à l'exécution est une valeur comme une autre — ajouter une stat = une ligne dans la table, et l'appel à Transform ne change pas.
Étape bonus (avancé) — SelectMany
Étape optionnelle — pour aller plus loin.
Les KDA sont calculés par jeu, mais le coaching staff veut la liste plate de tous les KDA de l'équipe, tous jeux confondus. Le problème : une collection de séries est une collection imbriquée — Select produirait une séquence de séquences.
var allSeries = new[] { kdaLea, kdaRaphael, kdaNoe, kdaDylan, kdaKiara };
// Select → IEnumerable<IEnumerable<double>> (imbriqué)
// SelectMany → IEnumerable<double> (aplati)
var allKda = allSeries.SelectMany(s => s.Values);
Console.WriteLine($"KDA de l'équipe entière : {allKda.Count()} valeurs");Vérification
kdaLea.Count= 13 (matchs de Léa uniquement)- Valeurs normalisées dans [0.0, 1.0] — min = 0.0, max = 1.0 exactement
Smooth(1)ne change rien (fenêtre = 1 = identité)Smooth(3)réduit les écarts entre valeurs consécutivesvalorant.Countreste 25 après toutes les transformations (immuabilité)