56 votes

Calcul de la moyenne mobile d'une liste

Ce week-end, j'ai décidé de m'essayer à Scala et Clojure. Je suis compétent en matière de programmation orientée objet, et Scala était donc facile à prendre en main en tant que langage, mais je voulais essayer la programmation fonctionnelle. C'est là que ça s'est compliqué.

Je n'arrive pas à me mettre dans un mode d'écriture fonctionnel. En tant que programmeur fonctionnel expert, comment abordez-vous un problème ?

Étant donné une liste de valeurs et une période définie de sommation, comment générer une nouvelle liste de la moyenne mobile simple de la liste ?

Par exemple : Étant donné la liste values (2.0, 4.0, 7.0, 6.0, 3.0, 8.0, 12.0, 9.0, 4.0, 1.0), et la period 4, la fonction devrait retourner : (0.0, 0.0, 0.0, 4.75, 5.0, 6.0, 7.25, 8.0, 8.25, 6.5)

Après avoir passé une journée à y réfléchir, le mieux que j'ai pu faire en Scala était ceci :

def simpleMovingAverage(values: List[Double], period: Int): List[Double] = {
  (for (i <- 1 to values.length)
    yield
    if (i < period) 0.00
    else values.slice(i - period, i).reduceLeft(_ + _) / period).toList
}

Je sais que c'est terriblement inefficace, je préfèrerais faire quelque chose comme :

where n < period: ma(n) = 0
where n = period: ma(n) = sum(value(1) to value(n)) / period
where n > period: man(n) = ma(n -1) - (value(n-period) / period) + (value(n) / period)

Il serait facile de le faire dans un style impératif, mais je n'arrive pas à trouver comment l'exprimer de manière fonctionnelle.

51voto

Daniel C. Sobral Points 159554

Un problème intéressant. Je peux penser à de nombreuses solutions, avec des degrés d'efficacité variables. Le fait de devoir ajouter des éléments à plusieurs reprises n'est pas vraiment un problème de performance, mais supposons que ce soit le cas. De plus, les zéros au début peuvent être ajoutés plus tard, donc ne nous inquiétons pas de les produire. Si l'algorithme les fournit naturellement, tant mieux ; sinon, nous corrigerons plus tard.

En commençant par Scala 2.8, le résultat suivant serait obtenu pour n >= period en utilisant sliding pour obtenir une fenêtre coulissante de la liste :

def simpleMovingAverage(values: List[Double], period: Int): List[Double] =
  List.fill(period - 1)(0.0) ::: (values sliding period map (_.sum) map (_ / period))

Néanmoins, bien que cette méthode soit plutôt élégante, elle n'offre pas les meilleures performances possibles, car elle ne tire pas profit des additions déjà calculées. Donc, en parlant d'eux, comment pouvons-nous les obtenir ?

Disons que nous écrivons ceci :

values sliding 2 map sum

Nous avons une liste de la somme de chacune des deux paires. Essayons d'utiliser ce résultat pour calculer la moyenne mobile de 4 éléments. La formule ci-dessus a permis de faire le calcul suivant :

from d1, d2, d3, d4, d5, d6, ...
to (d1+d2), (d2+d3), (d3+d4), (d4+d5), (d5+d6), ...

Ainsi, si nous prenons chaque élément et l'ajoutons au deuxième élément suivant, nous obtenons la moyenne mobile pour 4 éléments :

(d1+d2)+(d3+d4), (d2+d3)+(d4+d5), (d3+d4)+(d5+d6), ...

Nous pouvons le faire comme ceci :

res zip (res drop 2) map Function.tupled(_+_)

Nous pourrions alors calculer la moyenne mobile pour 8 éléments, et ainsi de suite. Il existe un algorithme bien connu pour calculer les choses qui suivent un tel modèle. Il est surtout connu pour son utilisation dans le calcul de la puissance d'un nombre. Il se présente comme suit :

def power(n: Int, e: Int): Int = e match {
  case 0 => 1
  case 1 => n
  case 2 => n * n
  case odd if odd % 2 == 1 => power(n, (odd - 1)) * n
  case even => power(power(n, even / 2), 2)
}

Alors, appliquons-le ici :

def movingSum(values: List[Double], period: Int): List[Double] = period match {
  case 0 => throw new IllegalArgumentException
  case 1 => values
  case 2 => values sliding 2 map (_.sum)
  case odd if odd % 2 == 1 => 
    values zip movingSum(values drop 1, (odd - 1)) map Function.tupled(_+_)
  case even =>
    val half = even / 2
    val partialResult = movingSum(values, half)
    partialResult zip (partialResult drop half) map Function.tupled(_+_)
}

Donc, voici la logique. La période 0 est invalide, la période 1 est égale à l'entrée, la période 2 est une fenêtre glissante de taille 2. Si elle est supérieure, elle peut être paire ou impaire.

Si c'est le cas, nous ajoutons chaque élément à l'ensemble des éléments de la liste. movingSum du prochain (odd - 1) éléments. Par exemple, s'il s'agit de 3, nous ajoutons chaque élément à la liste des movingSum des 2 éléments suivants.

S'il est pair, nous calculons le movingSum para n / 2 puis ajouter chaque élément à celui n / 2 les étapes suivantes.

Avec cette définition, nous pouvons alors revenir au problème et faire ceci :

def simpleMovingAverage(values: List[Double], period: Int): List[Double] =
  List.fill(period - 1)(0.0) ::: (movingSum(values, period) map (_ / period))

Il y a une légère inefficacité en ce qui concerne l'utilisation de ::: mais c'est O(period), pas O(values.size). Il peut être rendu plus efficace avec une fonction récursive de queue. Et, bien sûr, la définition de "sliding" que j'ai fournie est horrible du point de vue des performances, mais il y aura une bien meilleure définition de ce terme dans Scala 2.8. Notez que nous ne pouvons pas faire une fonction sliding sur un List mais nous pouvons le faire sur un Iterable .

Cela dit, j'opterais pour la toute première définition, et je n'optimiserais que si une analyse du chemin critique mettait en évidence l'importance du problème.

Pour conclure, voyons comment j'ai abordé le problème. Nous avons un problème de moyenne mobile. Une moyenne mobile est la somme d'une "fenêtre" mobile sur une liste, divisée par la taille de cette fenêtre. Donc, d'abord, j'essaie d'obtenir une fenêtre mobile, de faire la somme de tout ce qui s'y trouve, puis de diviser par la taille.

Le problème suivant était d'éviter la répétition d'additions déjà calculées. Dans ce cas, j'ai choisi l'addition la plus petite possible, et j'ai essayé de comprendre comment calculer des sommes plus importantes en réutilisant ces résultats.

Enfin, essayons de résoudre le problème de la manière dont vous l'avez imaginé, en ajoutant et en soustrayant au résultat précédent. Obtenir la première moyenne est facile :

 def movingAverage(values: List[Double], period: Int): List[Double] = {
   val first = (values take period).sum / period

Maintenant, nous faisons deux listes. D'abord, la liste des éléments à soustraire. Ensuite, la liste des éléments à ajouter :

   val subtract = values map (_ / period)
   val add = subtract drop period

Nous pouvons ajouter ces deux listes en utilisant zip . Cette méthode ne produira qu'autant d'éléments que la liste la plus petite a d'éléments, ce qui évite le problème des subtract être plus grand que nécessaire :

   val addAndSubtract = add zip subtract map Function.tupled(_ - _)

On termine en composant le résultat avec un pli :

   val res = (addAndSubtract.foldLeft(first :: List.fill(period - 1)(0.0)) { 
     (acc, add) => (add + acc.head) :: acc 
   }).reverse

qui est la réponse à renvoyer. L'ensemble de la fonction ressemble à ceci :

 def movingAverage(values: List[Double], period: Int): List[Double] = {
   val first = (values take period).sum / period
   val subtract = values map (_ / period)
   val add = subtract drop period
   val addAndSubtract = add zip subtract map Function.tupled(_ - _)
   val res = (addAndSubtract.foldLeft(first :: List.fill(period - 1)(0.0)) { 
     (acc, add) => (add + acc.head) :: acc 
   }).reverse
   res
 }

29voto

James Cunningham Points 472

Je connais mieux Clojure que Scala, alors c'est parti. Au moment où j'écris ces lignes, l'autre entrée Clojure est impérative ; ce n'est pas vraiment ce que vous recherchez (et ce n'est pas idiomatique de Clojure). Le premier algorithme qui me vient à l'esprit consiste à prendre de manière répétée le nombre d'éléments demandés dans la séquence, à laisser tomber le premier élément et à recommencer.

Ce qui suit fonctionne sur n'importe quel type de séquence (vecteur ou liste, paresseux ou non) et donne une séquence paresseuse de moyennes - ce qui peut être utile si vous travaillez sur une liste de taille indéfinie. Notez qu'elle prend soin du cas de base en retournant implicitement nil s'il n'y a pas assez d'éléments dans la liste à consommer.

(defn moving-average [values period]
  (let [first (take period values)]
    (if (= (count first) period)
      (lazy-seq 
        (cons (/ (reduce + first) period)
              (moving-average (rest values) period))))))

En exécutant cette opération sur vos données de test, vous obtenez

user> (moving-average '(2.0, 4.0, 7.0, 6.0, 3.0, 8.0, 12.0, 9.0, 4.0, 1.0) 4)
(4.75 5.0 6.0 7.25 8.0 8.25 6.5)

Il ne donne pas "0" pour les premiers éléments de la séquence, bien que cela puisse être facilement géré (de manière quelque peu artificielle).

Le plus simple est de voir le modèle et d'être capable de penser à une fonction disponible qui correspond à ce modèle. partition donne une vue paresseuse des portions d'une séquence, sur lesquelles nous pouvons ensuite établir une correspondance :

(defn moving-average [values period]
  (map #(/ (reduce + %) period) (partition period 1 values))

Quelqu'un a demandé une version récursive en queue ; la récursion en queue par rapport à la paresse est un peu un compromis. Lorsque votre travail consiste à construire une liste, il est généralement assez simple de rendre votre fonction récursive en queue, et ceci ne fait pas exception - il suffit de construire la liste comme argument d'une sous-fonction. Nous allons accumuler un vecteur au lieu d'une liste parce que sinon la liste sera construite à l'envers et devra être inversée à la fin.

(defn moving-average [values period]
  (loop [values values, period period, acc []]
    (let [first (take period values)]
      (if (= (count first) period)
        (recur (rest values) period (conj acc (/ (reduce + first) period)))
        acc))))

loop est un moyen de créer une fonction interne anonyme (un peu comme le let nommé de Scheme) ; recur doit être utilisé dans Clojure pour éliminer les appels de queue. conj est une généralisation cons en ajoutant de la manière naturelle pour la collection - le début des listes et la fin des vecteurs.

15voto

Jonas Points 8296

Voici une autre solution (fonctionnelle) en Clojure :

(defn avarage \[coll\]
  (/ (reduce + coll)
     (count coll)))

(defn ma \[period coll\]
  (map avarage (partition period 1 coll)))

Les zéros au début de la séquence doivent quand même être ajoutés si c'est une exigence.

13voto

Michał Marczyk Points 54179

Voici une solution purement fonctionnelle en Clojure. Plus complexe que celles déjà fournies, mais elle est paresseux y ajuste seulement la moyenne à chaque étape, au lieu de la recalculer à partir de zéro. . Elle est en fait plus lente qu'une solution simple qui calcule une nouvelle moyenne à chaque étape si la période est petite ; pour des périodes plus grandes, cependant, elle ne subit pratiquement aucun ralentissement, alors que quelque chose faisant (/ (take period ...) period) sera moins performant pendant de plus longues périodes.

(defn moving-average
  "Calculates the moving average of values with the given period.
  Returns a lazy seq, works with infinite input sequences.
  Does not include initial zeros in the output."
  [period values]
  (let [gen (fn gen [last-sum values-old values-new]
              (if (empty? values-new)
                nil
                (let [num-out (first values-old)
                      num-in  (first values-new)
                      new-sum (+ last-sum (- num-out) num-in)]
                  (lazy-seq
                    (cons new-sum
                          (gen new-sum
                               (next values-old)
                               (next values-new)))))))]
    (if (< (count (take period values)) period)
      nil
      (map #(/ % period)
           (gen (apply + (take (dec period) values))
                (cons 0 values)
                (drop (dec period) values))))))

9voto

Will Points 649

En voici un extrait sans point une solution Haskell en une ligne :

ma p = reverse . map ((/ (fromIntegral p)) . sum . take p) . (drop p) . reverse . tails

Il s'applique d'abord queues à la liste pour obtenir les listes de "queues", donc :

Prelude List> tails [2.0, 4.0, 7.0, 6.0, 3.0]
[[2.0,4.0,7.0,6.0,3.0],[4.0,7.0,6.0,3.0],[7.0,6.0,3.0],[6.0,3.0],[3.0],[]]

Inversez-le et laissez tomber les premières entrées "p" (en prenant p comme 2 ici) :

Prelude List> (drop 2 . reverse . tails) [2.0, 4.0, 7.0, 6.0, 3.0]
[[6.0,3.0],[7.0,6.0,3.0],[4.0,7.0,6.0,3.0],[2.0,4.0,7.0,6.0,3.0]]

Au cas où vous ne seriez pas familier avec le (.) point/nipple (g . f) signifie "exécuter f sur une valeur puis passer la sortie à g". Ainsi, ((f . g) x) est identique à (g(f x) x). (g . f) signifie "exécuter f sur une valeur puis passer la sortie à g", donc ((f . g) x) est identique à (g(f x)). En général, son utilisation conduit à un style de programmation plus clair.

Il fait ensuite correspondre la fonction ((/ (fromIntegral p)) . sum . take p) à la liste. Ainsi, pour chaque liste de la liste, elle prend les premiers éléments 'p', les additionne, puis les divise par 'p'. Ensuite, il suffit de retourner la liste avec "reverse".

Prelude List> map ((/ (fromIntegral 2)) . sum . take 2) [[6.0,3.0],[7.0,6.0,3.0]
,[4.0,7.0,6.0,3.0],[2.0,4.0,7.0,6.0,3.0]]
[4.5,6.5,5.5,3.0]

Tout cela semble beaucoup plus inefficace que ça ne l'est ; "reverse" n'inverse pas physiquement l'ordre d'une liste avant que celle-ci ne soit évaluée, il se contente de l'étaler sur la pile (bon vieux Haskell paresseux). De même, "tails" ne crée pas toutes ces listes séparées, il fait simplement référence à différentes sections de la liste originale. Ce n'est toujours pas une bonne solution, mais elle ne fait qu'une ligne :)

Voici une solution un peu plus agréable mais plus longue qui utilise mapAccum pour effectuer une soustraction et une addition glissantes :

ma p l = snd $ mapAccumL ma' a l'
    where
        (h, t) = splitAt p l
        a = sum h
        l' = (0, 0) : (zip l t)
        ma' s (x, y) = let s' = (s - x) + y in (s', s' / (fromIntegral p))

D'abord, nous divisons la liste en deux parties à "p", donc :

Prelude List> splitAt 2 [2.0, 4.0, 7.0, 6.0, 3.0]
([2.0,4.0],[7.0,6.0,3.0])

Résumez la première partie :

Prelude List> sum [2.0, 4.0]
6.0

Zippez la deuxième partie avec la liste originale (cela ne fait que jumeler les éléments des deux listes dans l'ordre). La liste originale est évidemment plus longue, mais nous perdons ce bit supplémentaire :

Prelude List> zip [2.0, 4.0, 7.0, 6.0, 3.0] [7.0,6.0,3.0]
[(2.0,7.0),(4.0,6.0),(7.0,3.0)]

Nous définissons maintenant une fonction pour notre mapAccum(ulator). mapAccumL est identique à "map", mais avec un paramètre supplémentaire d'état courant/accumulateur, qui est passé du "mapping" précédent au suivant lorsque map parcourt la liste. Nous utilisons l'accumulateur comme moyenne mobile, et comme notre liste est formée de l'élément qui vient de quitter la fenêtre de glissement et de l'élément qui vient d'y entrer (la liste que nous venons de zipper), notre fonction de glissement retire le premier nombre 'x' de la moyenne et ajoute le second nombre 'y'. Nous transmettons ensuite le nouveau 's' et retournons 's' divisé par 'p'. "snd" (second) prend simplement le second membre d'une paire (tuple), qui est utilisé pour prendre la seconde valeur de retour de mapAccumL, car mapAccumL retournera l'accumulateur ainsi que la liste mappée.

Pour ceux d'entre vous qui ne sont pas familiers avec le Symbole il s'agit de l'"opérateur d'application". Il ne fait pas grand-chose, mais il a une "préséance de liaison basse, associative à droite", ce qui signifie que vous pouvez laisser les parenthèses de côté (attention aux LISPistes), c'est-à-dire que (f x) est identique à f $ x.

L'exécution (ma 4 [2.0, 4.0, 7.0, 6.0, 3.0, 8.0, 12.0, 9.0, 4.0, 1.0]) donne [4.75, 5.0, 6.0, 7.25, 8.0, 8.25, 6.5] pour chaque solution.

Oh et vous devrez importer le module "List" pour compiler les deux solutions.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X