55 votes

Analyser un téraoctet de texte et compter efficacement le nombre d'occurrences de chaque mot.

Récemment, je suis tombé sur une question d'entretien consistant à créer un algorithme dans n'importe quel langage qui doit effectuer les opérations suivantes

  1. Lire 1 téraoctet de contenu
  2. Faites le compte de chaque mot récurrent dans ce contenu.
  3. Citez les 10 mots les plus fréquents

Pourriez-vous m'indiquer la meilleure façon de créer un algorithme pour cela ?

Edit :

Bon, disons que le contenu est en anglais. Comment pouvons-nous trouver les 10 premiers mots qui apparaissent le plus fréquemment dans ce contenu ? Mon autre doute est que, si les données sont uniques, notre tampon va expirer avec un dépassement de la taille du tas. Nous devons également gérer cela.

63voto

zeFrenchy Points 4154

Réponse à l'entretien

Cette tâche est intéressante sans être trop complexe, c'est donc un excellent moyen d'entamer une bonne discussion technique. Mon plan pour aborder cette tâche serait le suivant :

  1. Diviser les données d'entrée en mots, en utilisant les espaces blancs et la ponctuation comme délimiteurs.
  2. Introduisez chaque mot trouvé dans un Trie avec un compteur mis à jour dans les nœuds représentant la dernière lettre d'un mot.
  3. Traverser l'arbre entièrement peuplé pour trouver les nœuds ayant le plus grand nombre de points.

Dans le contexte d'un entretien... je démontrerais l'idée de Trie en dessinant l'arbre sur une planche ou un papier. Partez du vide, puis construisez l'arbre à partir d'une seule phrase contenant au moins un mot récurrent. Dites "le chat peut attraper la souris" . Enfin, je montrerai comment l'arbre peut ensuite être parcouru pour trouver les comptes les plus élevés. Je justifierai ensuite comment cet arbre permet une bonne utilisation de la mémoire, une bonne vitesse de recherche des mots (en particulier dans le cas du langage naturel pour lequel de nombreux mots dérivent les uns des autres), et est adapté au traitement parallèle.

Dessinez sur le tableau

Draw the example trie

Démo

Le programme C# ci-dessous parcourt 2 Go de texte en 75 secondes sur un xeon W3520 à 4 cœurs, en utilisant au maximum 8 threads. Les performances sont d'environ 4,3 millions de mots par seconde avec un code d'analyse d'entrée moins qu'optimal. Avec le Structure en trièdre pour stocker les mots, la mémoire n'est pas un problème lors du traitement des entrées en langage naturel.

Notes :

  • texte de test obtenu à partir du Projet Gutenberg
  • le code d'analyse d'entrée suppose des sauts de ligne et est plutôt sous-optimal
  • l'élimination de la ponctuation et d'autres éléments non verbaux n'est pas très bien faite
  • manipulation un gros fichier au lieu de plusieurs plus petites nécessiterait une petite quantité de code pour commencer à lire les fils entre les décalages spécifiés dans le fichier.

using System;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.IO;
using System.Threading;

namespace WordCount
{
    class MainClass
    {
        public static void Main(string[] args)
        {
            Console.WriteLine("Counting words...");
            DateTime start_at = DateTime.Now;
            TrieNode root = new TrieNode(null, '?');
            Dictionary<DataReader, Thread> readers = new Dictionary<DataReader, Thread>();

            if (args.Length == 0)
            {
                args = new string[] { "war-and-peace.txt", "ulysees.txt", "les-miserables.txt", "the-republic.txt",
                                      "war-and-peace.txt", "ulysees.txt", "les-miserables.txt", "the-republic.txt" };
            }

            if (args.Length > 0)
            {
                foreach (string path in args)
                {
                    DataReader new_reader = new DataReader(path, ref root);
                    Thread new_thread = new Thread(new_reader.ThreadRun);
                    readers.Add(new_reader, new_thread);
                    new_thread.Start();
                }
            }

            foreach (Thread t in readers.Values) t.Join();

            DateTime stop_at = DateTime.Now;
            Console.WriteLine("Input data processed in {0} secs", new TimeSpan(stop_at.Ticks - start_at.Ticks).TotalSeconds);
            Console.WriteLine();
            Console.WriteLine("Most commonly found words:");

            List<TrieNode> top10_nodes = new List<TrieNode> { root, root, root, root, root, root, root, root, root, root };
            int distinct_word_count = 0;
            int total_word_count = 0;
            root.GetTopCounts(ref top10_nodes, ref distinct_word_count, ref total_word_count);
            top10_nodes.Reverse();
            foreach (TrieNode node in top10_nodes)
            {
                Console.WriteLine("{0} - {1} times", node.ToString(), node.m_word_count);
            }

            Console.WriteLine();
            Console.WriteLine("{0} words counted", total_word_count);
            Console.WriteLine("{0} distinct words found", distinct_word_count);
            Console.WriteLine();
            Console.WriteLine("done.");
        }
    }

    #region Input data reader

    public class DataReader
    {
        static int LOOP_COUNT = 1;
        private TrieNode m_root;
        private string m_path;        

        public DataReader(string path, ref TrieNode root)
        {
            m_root = root;
            m_path = path;
        }

        public void ThreadRun()
        {
            for (int i = 0; i < LOOP_COUNT; i++) // fake large data set buy parsing smaller file multiple times
            {
                using (FileStream fstream = new FileStream(m_path, FileMode.Open, FileAccess.Read))
                {
                    using (StreamReader sreader = new StreamReader(fstream))
                    {
                        string line;
                        while ((line = sreader.ReadLine()) != null)
                        {
                            string[] chunks = line.Split(null);
                            foreach (string chunk in chunks)
                            {
                                m_root.AddWord(chunk.Trim());
                            }
                        }
                    }
                }
            }
        }
    }

    #endregion

    #region TRIE implementation

    public class TrieNode : IComparable<TrieNode>
    {
        private char m_char;
        public int m_word_count;
        private TrieNode m_parent = null;
        private ConcurrentDictionary<char, TrieNode> m_children = null;

        public TrieNode(TrieNode parent, char c)
        {
            m_char = c;
            m_word_count = 0;
            m_parent = parent;
            m_children = new ConcurrentDictionary<char, TrieNode>();            
        }

        public void AddWord(string word, int index = 0)
        {
            if (index < word.Length)
            {
                char key = word[index];
                if (char.IsLetter(key)) // should do that during parsing but we're just playing here! right?
                {
                    if (!m_children.ContainsKey(key))
                    {
                        m_children.TryAdd(key, new TrieNode(this, key));
                    }
                    m_children[key].AddWord(word, index + 1);
                }
                else
                {
                    // not a letter! retry with next char
                    AddWord(word, index + 1);
                }
            }
            else
            {
                if (m_parent != null) // empty words should never be counted
                {
                    lock (this)
                    {
                        m_word_count++;                        
                    }
                }
            }
        }

        public int GetCount(string word, int index = 0)
        {
            if (index < word.Length)
            {
                char key = word[index];
                if (!m_children.ContainsKey(key))
                {
                    return -1;
                }
                return m_children[key].GetCount(word, index + 1);
            }
            else
            {
                return m_word_count;
            }
        }

        public void GetTopCounts(ref List<TrieNode> most_counted, ref int distinct_word_count, ref int total_word_count)
        {
            if (m_word_count > 0)
            {
                distinct_word_count++;
                total_word_count += m_word_count;
            }
            if (m_word_count > most_counted[0].m_word_count)
            {
                most_counted[0] = this;
                most_counted.Sort();
            }
            foreach (char key in m_children.Keys)
            {
                m_children[key].GetTopCounts(ref most_counted, ref distinct_word_count, ref total_word_count);
            }
        }

        public override string ToString()
        {
            if (m_parent == null) return "";
            else return m_parent.ToString() + m_char;
        }

        public int CompareTo(TrieNode other)
        {
            return this.m_word_count.CompareTo(other.m_word_count);
        }
    }

    #endregion
}

Voici le résultat du traitement des mêmes 20MB de texte 100 fois sur 8 threads.

Counting words...
Input data processed in 75.2879952 secs

Most commonly found words:
the - 19364400 times
of - 10629600 times
and - 10057400 times
to - 8121200 times
a - 6673600 times
in - 5539000 times
he - 4113600 times
that - 3998000 times
was - 3715400 times
his - 3623200 times

323618000 words counted
60896 distinct words found

24voto

Jerry Coffin Points 237758

Beaucoup de choses dépendent ici de certaines choses qui n'ont pas été spécifiées. Par exemple, essayons-nous de faire ceci una vez ou essayons-nous de construire un système qui le fera de manière régulière et continue ? Avons-nous un quelconque contrôle sur les entrées ? S'agit-il de textes rédigés dans une seule langue (par exemple, l'anglais) ou de nombreuses langues sont-elles représentées (et si oui, combien) ?

Ils sont importants parce que :

  1. Si les données partent d'un seul disque dur, le comptage parallèle (par exemple, map-reduce) ne sera pas vraiment utile - le goulot d'étranglement sera la vitesse de transfert depuis le disque. Faire des copies sur plusieurs disques pour pouvoir compter plus rapidement sera plus lent que de compter directement à partir d'un seul disque.
  2. Si nous concevons un système pour faire cela régulièrement, la plupart de nos efforts portent sur le matériel - plus précisément, avoir beaucoup de disques en parallèle pour augmenter la bande passante et se rapprocher au moins un peu du rythme du CPU.
  3. Quelle que soit la quantité de texte que vous lisez, il y a une limite au nombre de mots distincts que vous devez traiter - que vous ayez un téraoctet ou même un pétaoctet de texte anglais, vous ne verrez pas des milliards de mots différents en anglais. En faisant une vérification rapide, l'Oxford English Dictionary répertorie environ 600 000 mots en anglais.
  4. Bien que les mots réels soient évidemment différents d'une langue à l'autre, le nombre de mots par langue est le suivant à peu près constante, de sorte que la taille de la carte que nous construisons dépendra fortement du nombre de langues représentées.

Cela laisse surtout la question de savoir combien de langues pourraient être représentées. Pour l'instant, supposons le pire des cas. L'ISO 639-2 a des codes pour 485 langues humaines. Supposons une moyenne de 700 000 mots par langue, et une longueur moyenne de mot de, disons, 10 octets d'UTF-8 par mot.

Si l'on stocke une simple liste linéaire, cela signifie que nous pouvons stocker tous les mots de toutes les langues de la planète, ainsi qu'un compte de fréquence de 8 octets, dans un peu moins de 6 gigaoctets. Si nous utilisons quelque chose comme une trie de Patricia à la place, nous pouvons probablement J'espère que ce nombre diminuera au moins un peu, peut-être même jusqu'à 3 gigaoctets ou moins, mais je ne connais pas assez ces langues pour en être sûr.

En réalité, il est presque certain que nous avons surestimé les chiffres à plusieurs endroits - un certain nombre de langues partagent un nombre important de mots, beaucoup de langues (surtout les plus anciennes) ont probablement moins de mots que l'anglais, et en jetant un coup d'œil à la liste, il semble que certaines soient incluses alors qu'elles n'ont probablement pas de formes écrites du tout.

Résumé : Presque tous les ordinateurs de bureau/serveurs raisonnablement récents ont assez de mémoire pour contenir la carte entièrement en RAM -- et plus de données n'y changeront rien. Pour un (ou quelques) disques en parallèle, nous serons de toute façon limités par les E/S, donc le comptage parallèle (et autres) sera probablement une perte nette. Nous avons probablement besoin de dizaines de disques en parallèle avant que toute autre optimisation ne soit significative.

14voto

amit Points 74385

Vous pouvez essayer un map-reduce pour cette tâche. L'avantage de map-reduce est l'évolutivité, donc même pour 1TB, ou 10TB ou 1PB - la même approche fonctionnera, et vous n'aurez pas besoin de faire beaucoup de travail afin de modifier votre algorithme pour la nouvelle échelle. Le framework se chargera également de répartir le travail entre toutes les machines (et les cœurs) de votre cluster.

Tout d'abord, créez le (word,occurances) paires.
Le pseudo-code pour cela sera quelque chose comme ça :

map(document):
  for each word w:
     EmitIntermediate(w,"1")

reduce(word,list<val>):
   Emit(word,size(list))

Deuxièmement, vous pouvez facilement trouver celles qui ont les K occurrences les plus élevées en une seule itération sur les paires, Ce fil explique ce concept. L'idée principale est de tenir un mini-heap des K premiers éléments, et pendant l'itération - s'assurer que le heap contient toujours les K premiers éléments vus jusqu'à présent. Lorsque vous avez terminé, le tas contient les K premiers éléments.

Une alternative plus évolutive (bien que plus lente si vous disposez de peu de machines) consiste à utiliser la fonctionnalité de tri de map-reduce, à trier les données en fonction des occurrences et à rechercher les K principaux.

9voto

Will Hartung Points 57465

Trois choses à noter à ce sujet.

Plus précisément : Fichier trop grand pour être conservé en mémoire, liste de mots (potentiellement) trop grande pour être conservée en mémoire, le nombre de mots peut être trop grand pour un int 32 bits.

Une fois ces réserves levées, tout devrait être simple. Le jeu consiste à gérer une liste de mots potentiellement importante.

Si c'est plus facile (pour éviter que votre tête ne tourne).

"Vous utilisez une machine Z-80 8 bits, avec 65K de RAM et vous avez un fichier de 1MB..."

Exactement le même problème.

5voto

alienhard Points 5837

Cela dépend des exigences, mais si vous pouvez vous permettre quelques erreurs, algorithmes de streaming y structures de données probabilistes peuvent être intéressantes car elles sont très efficaces en termes de temps et d'espace et assez simples à mettre en œuvre, par exemple :

  • Les mots les plus fréquents (par exemple, Space Saving), si vous ne vous intéressez qu'aux n mots les plus fréquents.
  • Esquisse de comptage-min, pour obtenir un comptage estimé pour n'importe quel mot

Ces structures de données ne nécessitent que très peu d'espace constant (la quantité exacte dépend de l'erreur que vous pouvez tolérer).

Ver http://alex.smola.org/teaching/berkeley2012/streams.html pour une excellente description de ces algorithmes.

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