Skip to content

Exercice 04 — Calculer le KDA par joueur ​

Partie 3 — .Transform() + Normalize() + Smooth() (hors DataSeries<T>, temporaire)

Concepts théoriques ​

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).

csharp
public DataSeries<TResult> Transform<TResult>(Func<T, TResult> mapper)
{
    // appliquer mapper à chaque élément de _data et retourner une nouvelle DataSeries
    // ...
}
Voir la solution
csharp
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 :

csharp
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 : Transform change le type — DataSeries<ValorantMatch> devient DataSeries<double>. La bibliothèque reste générique, le domaine est dans EsportApp.

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>.Normalize a besoin de calculer max - min, une soustraction qui n'a de sens que pour double. Or DataSeries<T> reste générique sur T (il sert aussi pour ValorantMatch, Cs2Match...), donc une méthode d'instance Normalize() dans cette classe ne compilerait pas pour tous les T. La vraie solution — une méthode d'extension qui ajoute Normalize uniquement à DataSeries<double> — demande une notion pas encore vue (exercice 06). En attendant, Normalize n'est pas une méthode de DataSeries<T> : c'est une fonction static isolée dans un utilitaire à part (DataSeries/MathHelpers.cs), appelée explicitement — MathHelpers.Normalize(series) et non series.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 :

MatchKDA brutKDA normalisé
11.20.00
21.80.25
33.61.00
42.40.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.

csharp
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
csharp
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 :

csharp
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 : Smooth sort elle aussi de DataSeries<T> et rejoint MathHelpers, appelée explicitement (MathHelpers.Smooth(series, windowSize) plutôt que series.Smooth(windowSize)), avec series à la place de _data.

csharp
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
csharp
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 :

csharp
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 window n'est pas capturée : elle est passée en argument à Smooth, donc copiée. C'est windowSize (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
csharp
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.

csharp
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");

→ SelectMany — le flatMap


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écutives
  • valorant.Count reste 25 après toutes les transformations (immuabilité)