URL: https://linuxfr.org/news/predire-la-note-d-un-journal-sur-linuxfr-org Title: Prédire la note d’un journal sur LinuxFr.org Authors: jnanar BAud, Jean-Baptiste Faure, Yves Bourguignon, Davy Defaud, ZeroHeure, Nicolas Casanova, Nils Ratusznik, palm123, bubar🦥, j_m, Benoît Sibaud, claudex, Nÿco et Pierre Jarillon Date: 2017年05月24日T20:59:27+02:00 License: CC By-SA Tags: python, karma, statistiques, datamining, linuxfr, notation et machine_learning Score: 139 Cette dépêche traite de l’exploration de données sur des données issues de _LinuxFr.org_. Ayant découvert récemment [_scikit-learn_](https://fr.wikipedia.org/wiki/Scikit-learn), une bibliothèque Python d’apprentissage statistique (_machine learning_). Je voulais partager ici un début d’analyse sur des contenus issus de _LinuxFr.org_. _Avertissement : je ne suis pas programmeur, ni statisticien. Je ne maîtrise pas encore tous les arcanes de scikit-learn et de nombreux éléments théoriques m’échappent encore. Je pense néanmoins que les éléments présentés ici pourront en intéresser plus d’un(e)._ Tous les scripts sont codés en Python et l’analyse à proprement parler a été réalisée à l’aide d’un _notebook_ [Jupyter](https://fr.wikipedia.org/wiki/Jupyter). Un dépôt contenant les données et les scripts est disponible sur [_GitLab_](https://gitlab.com/jnanar/scikit-linuxfr). ---- [Dépôt GitLab de l’analyse](https://gitlab.com/jnanar/scikit-linuxfr) [Scikit-learn](http://scikit-learn.org/) [Beautifulsoup](https://www.crummy.com/software/BeautifulSoup/) [Python Data Analysis Library (pandas)](http://pandas.pydata.org/) ---- # Prédire la note d’un journal Il y a eu récemment une vague de journaux politiques sur DLFP. La note de la plupart de ces journaux était assez basse. Par ailleurs, on lit régulièrement ici des personnes qui se plaignent de la note de leurs articles. Bien souvent, des gens postent des contenus incendiaires, parfois en rafale. Je me suis demandé si cela est évitable. _Est-il possible de prédire la note d'un journal en fonction de son contenu?_ Le problème est ambitieux mais il permettrait aux auteurs d'avoir une idée de l’accueil qui sera réservé à leur prose. Prédire un score me paraît hasardeux, c'est pourquoi j'ai préféré classer les journaux dans 4 catégories en fonction de leur note, **n** (en _english_ car il est bien connu que ça _improve_ la _productivitaÿ_) : * n < -20 : _Magnificent Troll_ ; * -20 < n < 0 : _Great Troll_ ; * 0 < n < 20 : _Average Troll_ ; * 20 < n : _Qualitaÿ Troll_. Vous l'aurez compris, tout contenu est un _Troll_, car je pense que nous sommes tous le troll d'un autre. # Obtenir les données Il n'existe pas à ma connaissance de base de données de DLFP disponible pour tests. Après avoir lu [deux](http://linuxfr.org/users/krunch/journaux/rapport-signal-bruit-et-filtre-passe-haut) [journaux](http://linuxfr.org/users/palkeo/journaux/de-la-prediction-de-l-auteur-d-un-journal-sur-linuxfr) précédents, j'ai décidé de construire une moulinette afin d'aspirer une partie du contenu. ## Approche 1: le flux atom Dans un premier temps, j'ai utilisé le flux atom des journaux à l'aide de la bibliothèque [feedparser](https://pypi.python.org/pypi/feedparser). Le [script](https://gitlab.com/jnanar/scikit-linuxfr/blob/master/atom_to_csv.py) fonctionne et l'approche est très simple mais malheureusement, la quantité de données est trop limitées. Par ailleurs, le score d'un contenu n'est pas disponible dans les flux. J'ai donc changé mon fusil d'épaule. ## Approche 2: l'heure de la soupe Afin d'augmenter le volume de données, il faut parcourir la page https://linuxfr.org/journaux?page=x et collecter tous les liens vers les différents journaux. Chaque journal est ensuite analysé. Dans un premier temps, les informations suivantes sont utilisées : le nom de l'auteur, le titre du journal, l'URL, le contenu du journal, sa note. La [moulinette](https://gitlab.com/jnanar/scikit-linuxfr/blob/master/linuxfr_parser.py) s'appuie sur la bibliothèque [Beautiful Soup4](https://pypi.python.org/pypi/beautifulsoup4/4.6.0). Les données sont enregistrées dans un fichier CSV. Étant donné que le contenu des journaux est très varié, j'ai choisi les caractères μ et £ en tant que délimiteur et séparateur, respectivement. # Analyse des données L'analyse suivante est réalisée à l'aide du fichier [diaries_classification.ipynb](https://gitlab.com/jnanar/scikit-linuxfr/blob/master/diaries_classification.ipynb). La lecture du fichier CSV `linuxfr.csv` montre qu'il contient 5921 journaux. 302 Magnificents Trolls, 460 Great Trolls, 2545 Quality Trolls et 2614 Average Trolls. Étant donné que les données sont déséquilibrées, il faudra en tenir compte dans les travaux car ces chiffres influencent les probabilités. ```python import matplotlib.pyplot as plt import pandas as pd import numpy as np import sys import matplotlib # Enable inline plotting %matplotlib inline filename = r'linuxfr.csv' lf_data = pd.read_csv(filename, encoding="UTF-8", sep='£', engine='python', quotechar='μ') ``` ```python len(lf_data) 5921 ``` ```python lf_data.quality_content.value_counts() Average Troll 2614 Quality Troll 2545 Great Troll 460 Magnificent Troll 302 Name: quality_content, dtype: int64 ``` ```python lf_data.quality_content.value_counts().plot(kind='bar') plt.ylabel('Occurences', fontsize='xx-large') plt.yticks(fontsize='xx-large') plt.xlabel('Trolls', fontsize='xx-large') plt.xticks(fontsize='xx-large') ``` ![Analyse des journaux](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/nbre_journaux_1.png) Au passage, on observe qu'il y a beaucoup plus de contenu de qualité (pertinent), dont le score est positif que de négatif. Ou encore, qu'il y a beaucoup plus de contenu avec lequel les votants sont d'accord. ## L’affaire est dans le sac (de mots) À ce stade, j'ai suivi la documentation [officielle de scikit-learn](http://scikit-learn.org/stable/tutorial/text_analytics/working_with_text_data.html). L'analyse de texte est le plus souvent basée sur un algorithme de type ["Bag of words"](https://fr.wikipedia.org/wiki/Sac_de_mots). Chaque mot est compté dans le texte. On est alors en mesure de tracer un histogramme du nombre d’occurrence des mots en fonction de la liste des mots du dictionnaire. Dans scikit-learn, l'utilisation d'un sac de mots est très simple. Il faut faire appel à la classe CountVectorizer. Ma base de 5921 journaux contient 78879 mots différents. ```python import numpy as np from sklearn.feature_extraction.text import CountVectorizer count_vect = CountVectorizer() X_train_counts = count_vect.fit_transform(lf_data['content'].values) X_train_counts.shape (5921, 78879) ``` ## Utiliser les fréquences d'apparition des mots L'inconvénient du comptage de mots est qu'il entraîne un déséquilibre entre les textes de longueur différente. Il est possible de calculer les fréquences (tf) et éventuellement diminuer l'impact des mots qui apparaissent dans beaucoup de documents tels que les pronoms (tf-idf). L'utilisation de ces algorithmes est tout aussi simple : ```python from sklearn.feature_extraction.text import TfidfTransformer tf_transformer = TfidfTransformer(use_idf=False).fit(X_train_counts) X_train_tf = tf_transformer.transform(X_train_counts) ``` # Classifier les articles ## Approche naïve : filtrage bayésien La manière la plus simple d'analyser les articles est d'utiliser la [classification naïve bayésienne](https://fr.wikipedia.org/wiki/Classification_na%C3%AFve_bay%C3%A9sienne). Wikipedia éclaire un peu plus les concepts sous-jacents :> En termes simples, un classificateur bayésien naïf suppose que l'existence d'une caractéristique pour une classe, est indépendante de l'existence d'autres caractéristiques. Un fruit peut être considéré comme une pomme s'il est rouge, arrondi, et fait une dizaine de centimètres. Même si ces caractéristiques sont liées dans la réalité, un classificateur bayésien naïf déterminera que le fruit est une pomme en considérant indépendamment ces caractéristiques de couleur, de forme et de taille. Une fois le modèle entraîné (fonction _fit_, d'adéquation en français), il est possible de prédire à quelle catégorie des articles appartiennent. ```python from sklearn.naive_bayes import MultinomialNB classifier = MultinomialNB() classifier.fit(X_train_tfidf, targets) training_journals = ['Sécuriser son serveur avec la commande sudo rm -rf /*', 'Debian is dying', 'Windows Millenium est meilleur que Linux sur calculatrice graphique', "MultiDeskOS est 42% plus performant que Redhat 3.0.3 (Picasso)", "Pierre Tramo président !", "Des chocolatines au menu des cantines situées dans les DOM-TOM", "1515, l’année du Desktop Linux!"] X_new_counts = count_vect.transform(training_journals) X_new_tfidf = tfidf_transformer.transform(X_new_counts) predicted = classifier.predict(X_new_tfidf) for doc, category in zip(training_journals, predicted): print('%r => %s' % (doc, category)) ``` 'Sécuriser son serveur avec la commande sudo rm -rf /*' => Quality Troll 'Debian is dying' => Quality Troll 'Windows Millenium est meilleur que Linux sur calculatrice graphique' => Quality Troll 'MultiDeskOS est 42% plus performant que Redhat 3.0.3 (Picasso)' => Average Troll 'Pierre Tramo président !' => Average Troll 'Des chocolatines au menu des cantines situées dans les DOM-TOM' => Quality Troll '1515, l’année du Desktop Linux!' => Average Troll La commande ``predict_proba`` permet d'afficher les probabilités. Il en ressort que la marge d'erreur est énorme. ```python predicted_proba = classifier.predict_proba(X_new_tfidf) print(targets_names) predicted_proba ['Average Troll', 'Great Troll', 'Magnificent Troll', 'Quality Troll'] array([[ 0.38146407, 0.01242555, 0.00699732, 0.59911306], [ 0.45180296, 0.03300345, 0.01880854, 0.49638505], [ 0.37809693, 0.0190014 , 0.00917897, 0.5937227 ], [ 0.47083803, 0.0629247 , 0.02837355, 0.43786371], [ 0.54130358, 0.04642992, 0.03861831, 0.37364818], [ 0.45172753, 0.03297976, 0.01805764, 0.49723507], [ 0.59237292, 0.01164186, 0.00420374, 0.39178148]]) ``` Mes "journaux" sont beaucoup trop courts pour être représentatifs, enfin cela dépend de la définition de "contenu de qualité". Par conséquent, il faut tester le modèle sur l'archive des contenus, dans un premier temps. Pour y arriver, je définis un pipeline qui consiste à assembler les étapes décrites précédemment dans un objet qui se comporte comme un classificateur. ```python from sklearn.pipeline import Pipeline text_clf = Pipeline([('vect', CountVectorizer()), ('tfidf', TfidfTransformer()), ('clf', MultinomialNB()),]) ``` ### Tester le modèle avec les journaux connus Je commence par échantillonner 20 % des journaux de la base de données et je teste le modèle sur cet ensemble, afin de voir s'il est capable de retrouver la bonne catégorie. ```python diaries_test = lf_data.sample(frac=0.2) predicted = text_clf.predict(diaries_test['quality_content']) ``` ```python from sklearn.metrics import confusion_matrix, f1_score score = f1_score(diaries_test['quality_content'], predicted, average='weighted') print('Diaries:', len(diaries_test)) print('Score:', score) Diaries: 5921 Score: 0.269979533821 /usr/lib/python3.6/site-packages/sklearn/metrics/classification.py:1113: UndefinedMetricWarning: F-score is ill-defined and being set to 0.0 in labels with no predicted samples. 'precision', 'predicted', average, warn_for) ``` Ça ne marche pas du tout. La raison pour laquelle ce message est affiché est que le paramètre F ([score F1](https://en.wikipedia.org/wiki/F1_score)) est indéterminé. Ce paramètre est un estimateur de la qualité d'une classification. Il dépend de la [précision et du rappel](https://fr.wikipedia.org/wiki/Pr%C3%A9cision_et_rappel). Une image vaut mieux qu'un long discours, le dessin sur la page wikipedia : ![précision et rappel](https://upload.wikimedia.org/wikipedia/commons/thumb/2/26/Precisionrecall.svg/350px-Precisionrecall.svg.png) [La matrice de confusion](https://fr.wikipedia.org/wiki/Matrice_de_confusion) permet de comprendre pourquoi le score F est si mauvais : mis à part pour les trolls de qualité, je n'ai pas de vrai positif ! Pour lire le graphique : la prédiction parfaite aurait 100 % sur chaque case de la diagonale. C'est le cas ici des _qualitaÿ trolls_ qui sont tous bien identifiés. Mais il y a un biais vers les _qualitaÿ trolls_. L'algorithme interprète ainsi erronément 100 % des _average trolls_ comme des _qualitaÿ trolls_ par exemple. ![mat_NB](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/mat_NB_1.png) ![mat_NB_norm](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/mat_NB_norm_1.png) Au passage, j'affiche la matrice de confusion à l'aide du code de la [documentation officielle](http://scikit-learn.org/stable/auto_examples/model_selection/plot_confusion_matrix.html). Mon classificateur est mauvais. Il est probablement possible d'en améliorer les performances mais j'ai préféré changer d’algorithme. ## Support vector machine (SVM) D'après la [documentation](http://scikit-learn.org/stable/tutorial/text_analytics/working_with_text_data.html) officielle, il s'agit de l’algorithme de classification de texte le plus performant pour le texte. [SGDClassifier](http://scikit-learn.org/stable/modules/generated/sklearn.linear_model.SGDClassifier.html) est basé sur un classificateur linéaire et un algorithme du gradient stochastique (abréviation SGD). Je vous avoue ne pas encore maîtriser ces subtilités. Si quelqu'un à l'aise avec ces notions veut participer à la discussion, il est le bienvenu. ```python from sklearn.linear_model import SGDClassifier text_clf = Pipeline([('vect', CountVectorizer()), ('tfidf', TfidfTransformer()), ('clf', SGDClassifier()),]) _ = text_clf.fit(lf_data.content, lf_data.quality_content) predicted = text_clf.predict(diaries_test.content) np.mean(predicted == diaries_test.quality_content) 0.95 ``` Le score est très bon. Il est possible d'afficher plus d'informations à propos des prédictions : ```python from sklearn import metrics print(metrics.classification_report(diaries_test.quality_content, predicted, target_names=targets_names)) precision recall f1-score support Average Troll 0.99 0.93 0.96 523 Great Troll 1.00 0.94 0.97 80 Magnificent Troll 1.00 0.94 0.97 72 Quality Troll 0.92 0.99 0.96 509 avg / total 0.96 0.96 0.96 1184 ``` ```python # Affichage de la matrice de confusion metrics.confusion_matrix(diaries_test.quality_content, predicted) # Compute confusion matrix import itertools cnf_matrix = confusion_matrix(diaries_test['quality_content'], predicted) np.set_printoptions(precision=2) # Plot non-normalized confusion matrix plt.figure() plot_confusion_matrix(cnf_matrix, classes=targets_names, title='Confusion matrix, without normalization') # Plot normalized confusion matrix plt.figure() plot_confusion_matrix(cnf_matrix, classes=targets_names, normalize=True, title='Normalized confusion matrix') plt.show() Confusion matrix, without normalization Confusion matrix, without normalization [[489 0 0 34] [ 2 75 0 3] [ 0 0 68 4] [ 5 0 0 504]] Normalized confusion matrix [[ 0.93 0. 0. 0.07] [ 0.03 0.94 0. 0.04] [ 0. 0. 0.94 0.06] [ 0.01 0. 0. 0.99]] ``` ![matrice de confusion 0.95](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/mat_score_095_1.png) ![matrice de confusion 0.95 norm](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/mat_score_095_norm_1.png) # Validation croisée Ces résultats sont très intéressants mais il est important de tester la solidité du modèle. Cette étape est appelée [validation croisée](https://fr.wikipedia.org/wiki/Validation_crois%C3%A9e). scikit-learn permet de réaliser ces tests de manière automatisée. L'idée est d’échantillonner une partie des journaux (10 % dans notre cas), d'entraîner le modèle sur les 90 % restant et de tester le modèle sur ces 10 % "caché". On affiche ensuite les scores pondérés en fonction du nombre d’occurrence de journaux dans chaque catégorie. ```python from sklearn.model_selection import cross_val_score scores = cross_val_score(text_clf, # steps to convert raw messages into models lf_data.content, # training data lf_data.quality_content, # training labels cv=10, # split data randomly into 10 parts: 9 for training, 1 for scoring scoring='accuracy', # which scoring metric? n_jobs=-1, # -1 = use all cores = faster ) print(scores) print('Total diaries classified:', len(lf_data)) print('Score:', sum(scores)/len(scores)) [ 0.54 0.53 0.55 0.55 0.56 0.57 0.54 0.52 0.56 0.56] Total diaries classified: 5921 Score: 0.548226957256 ``` Le score est égal à 0.55. Ce n'est pas terrible. Si on préfère afficher la matrice de confusion, il faut utiliser les Kfold qui reposent sur le même principe que `cross_val_score` et implémenter une boucle. ```python from sklearn.model_selection import KFold from sklearn.metrics import confusion_matrix, f1_score,precision_score k_fold = KFold(n_splits=10) scores = [] confusion = np.array([[0, 0,0,0], [0, 0,0,0], [0, 0,0,0], [0, 0,0,0]]) for train_indices, test_indices in k_fold.split(lf_data): train_text = lf_data.iloc[train_indices]['content'].values train_y = lf_data.iloc[train_indices]['quality_content'].values test_text = lf_data.iloc[test_indices]['content'].values test_y = lf_data.iloc[test_indices]['quality_content'].values text_clf.fit(train_text, train_y) predictions = text_clf.predict(test_text) confusion += confusion_matrix(test_y, predictions) score = f1_score(test_y, predictions, average='weighted') ps = precision_score(test_y, predictions, average='weighted') scores.append(score) print('Total diaries classified:', len(lf_data)) print('Score:', sum(scores)/len(scores)) print('Confusion matrix:') print(confusion) Total diaries classified: 5921 Score: 0.519244446873 Confusion matrix: [[1475 22 13 1104] [ 253 11 16 180] [ 164 15 26 97] [ 794 7 8 1736]] scores [0.48812704076867125, 0.50096444244611738, 0.53296513209879548, 0.50865953156976373, 0.53358760110311787, 0.52464153844229733, 0.53897239391380014, 0.5090212038928732, 0.5340084448235829, 0.5214971396677468] ``` ![validation_croisée](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/mat_cross_1.png) ![validation_croisée norm](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/mat_cross_norm_1.png) Comme on le voit, les résultats sont très mauvais. Environ 44 % des journaux "Average Troll" sont attribués à la classe "Quality Troll" ! **Si les auteurs suivent la même logique que cet algorithme**, ils ont tendance à sur-estimer fortement leurs écrits. De même, 30 % des "Quality Troll" sont attribués à la classe "Average Troll". **En suivant cette logique**, les auteurs de contenu de qualité auraient tendance à se sous-estimer. Par ailleurs, il faut noter que ces classes sont voisines : score de 0 à 20 et de 20 à l'infini (et au delà). Plus inquiétant : les contenus avec un score négatif sont attribués majoritairement aux classes à score positif. **Un auteur de contenu moinsé qui penserait comme la machine serait persuadé que son texte est de qualité.** Il ne comprendrait pas le score négatif qui en résulte. # Optimisation des paramètres Et si nos mauvais résultats étaient dus au choix d'un mauvais jeu de paramètres de départ ? Le _pipeline_ choisi dépend de nombreux paramètres ajustables. Scikit-learn permet d'optimiser ces paramètres facilement afin de trouver le meilleur compromis. ```python from sklearn.linear_model import SGDClassifier text_clf = Pipeline([('vect', CountVectorizer()), ('tfidf', TfidfTransformer()), ('clf', SGDClassifier()),]) ``` Les paramètres ajustables sont précédés du nom de l'étape correspondante. Les explications concernant ces paramètres sont disponibles dans la documentation officielle : * [CountVectorizer](http://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.CountVectorizer.html) ; * [TfidfTransformer](http://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfTransformer.html) ; * [SGDClassifier](http://scikit-learn.org/stable/modules/generated/sklearn.linear_model.SGDClassifier.html). ```python sorted(text_clf.get_params().keys()) ['clf', 'clf__alpha', 'clf__average', 'clf__class_weight', 'clf__epsilon', 'clf__eta0', 'clf__fit_intercept', 'clf__l1_ratio', 'clf__learning_rate', 'clf__loss', 'clf__n_iter', 'clf__n_jobs', 'clf__penalty', 'clf__power_t', 'clf__random_state', 'clf__shuffle', 'clf__verbose', 'clf__warm_start', 'steps', 'tfidf', 'tfidf__norm', 'tfidf__smooth_idf', 'tfidf__sublinear_tf', 'tfidf__use_idf', 'vect', 'vect__analyzer', 'vect__binary', 'vect__decode_error', 'vect__dtype', 'vect__encoding', 'vect__input', 'vect__lowercase', 'vect__max_df', 'vect__max_features', 'vect__min_df', 'vect__ngram_range', 'vect__preprocessor', 'vect__stop_words', 'vect__strip_accents', 'vect__token_pattern', 'vect__tokenizer', 'vect__vocabulary'] ``` Le code ci-dessous permet d'ajuster les paramètres suivants : * `tfidf__use_idf` : utilisation ou non de la pondération par la fréquence des mots ; * `clf__loss` : le type de fonction qui "caractérise" la perte ou encore le type de transition entre les catégories. Cette transition peut être abrupte ou plus ou moins lissée. ([illustration du phénomène (P 20)](http://www.cmap.polytechnique.fr/~giraud/MAP553/sec3.4-3.6correction.pdf)) ; * `clf__alpha` [un paramètre mathématique strictement positif](http://scikit-learn.org/stable/modules/svm.html#svc). Évidemment, le temps de calcul dépend du nombre de paramètres à ajuster. Les autres paramètres sont laissés à leur valeur par défaut. ```python params = { 'tfidf__use_idf': (True, False), 'clf__loss':('huber', 'modified_huber', 'epsilon_insensitive', 'hinge', 'log'), 'clf__alpha':(1,0.001, 0.00001),} gs_clf = GridSearchCV(text_clf, params, n_jobs=-1, verbose=0, refit=True,scoring='accuracy',) print("Performing grid search...") print("pipeline:", [name for name, _ in text_clf.steps]) print("parameters:") print(params) t0 = time() gs_clf = gs_clf.fit(lf_data.content, targets) print("done in %0.3fs" % (time() - t0)) print() print("Best score: %0.3f" % gs_clf.best_score_) print("Best parameters set:") best_parameters = gs_clf.best_estimator_.get_params() for param_name in sorted(params.keys()): print("\t%s: %r" % (param_name, best_parameters[param_name])) ``` Ce qui donne : ```python Performing grid search... pipeline: ['vect', 'tfidf', 'clf'] parameters: {'tfidf__use_idf': (True, False), 'clf__loss': ('huber', 'modified_huber', 'epsilon_insensitive', 'hinge', 'log'), 'clf__alpha': (1, 0.001, 1e-05)} done in 108.027s Best score: 0.547 Best parameters set: clf__alpha: 0.001 clf__loss: 'modified_huber' tfidf__use_idf: True ``` Malheureusement, le score semble encore assez bas. Par ailleurs, le meilleur estimateur est également disponible pour utilisation future : ```python gs_clf.best_estimator_ Pipeline(steps=[('vect', CountVectorizer(analyzer='word', binary=False, decode_error='strict', dtype=, encoding='utf-8', input='content', lowercase=True, max_df=1.0, max_features=None, min_df=1, ngram_range=(1, 1), preprocessor=None, stop_words=None, strip... penalty='l2', power_t=0.5, random_state=None, shuffle=True, verbose=0, warm_start=False))]) ``` ## Test sur un échantillon de données connues Comme précédemment, il est possible de tester le modèle sur un échantillon de données connues. L'ajustement a été réalisé avec le meilleur jeu de paramètres grâce à l'option `refit=True` passée à `GridSearchCV`. Les résultats du score F1 sont encore une fois très bons mais l'amélioration du score est nulle : il plafonne entre 0.95 et 0.96. ```python print(metrics.classification_report(diaries_test.quality_content, predicted, target_names=targets_names)) precision recall f1-score support Average Troll 0.99 0.93 0.96 523 Great Troll 1.00 0.94 0.97 80 Magnificent Troll 1.00 0.94 0.97 72 Quality Troll 0.92 0.99 0.96 509 avg / total 0.96 0.96 0.96 1184 ``` De même, la matrice de confusion est excellente : ![mat_grid](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/mat_grid_1.png) ![mat_grid_norm](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/mat_grid_norm_1.png) ## Test sur un échantillon de données inconnues Pour aller plus loin, j'ai testé le modèle sur de nouvelles données (des journaux plus anciens). Ces données ne font pas partie de mes journaux de base. En principe, le résultat sera similaire à ce qu'on obtient par validation croisée mais cette technique a pour avantage d'augmenter la taille de la base de journaux disponibles. Une autre possibilité consiste à relancer la validation croisée après avoir fusionné ces nouvelles données aux anciennes. ```python filename = r'out_of_sample.csv' lf_out = pd.read_csv(filename, encoding="UTF-8", sep='£', engine='python', quotechar='μ') lf_out = lf_out.reindex(np.random.permutation(lf_out.index)) lf_out.quality_content.value_counts().plot(kind='bar') plt.ylabel('Occurences', fontsize='xx-large') plt.yticks(fontsize='xx-large') plt.xlabel('Trolls', fontsize='xx-large') plt.xticks(fontsize='xx-large') ``` Ces nouvelles données sont similaires aux journaux déjà disponibles. ![out_of_sample](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/out_of_sample_1.png) ```python predicted_out = text_clf.predict(lf_out.content) np.mean(predicted_out == lf_out.quality_content) score_out = f1_score(lf_out['quality_content'], predicted_out, average='weighted') print('Diaries:', len(lf_out)) print('Score:', score_out) cnf_matrix_out = confusion_matrix(lf_out['quality_content'], predicted_out) np.set_printoptions(precision=2) # Plot non-normalized confusion matrix plt.figure() plot_confusion_matrix(cnf_matrix_out, classes=targets_names, title='Confusion matrix, without normalization') # Plot normalized confusion matrix plt.figure() plot_confusion_matrix(cnf_matrix_out, classes=targets_names, normalize=True, title='Normalized confusion matrix') plt.show() print(metrics.classification_report(lf_out.quality_content, predicted_out, target_names=targets_names)) Diaries: 1500 Score: 0.444809984556 Confusion matrix, without normalization [[452 9 10 457] [ 90 4 0 52] [ 42 5 6 26] [126 2 0 219]] Normalized confusion matrix [[ 0.49 0.01 0.01 0.49] [ 0.62 0.03 0. 0.36] [ 0.53 0.06 0.08 0.33] [ 0.36 0.01 0. 0.63]] ``` ![mat_out](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/mat_out_1.png) ![mat_out_norm](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/mat_out_norm_1.png) ```python precision recall f1-score support Average Troll 0.64 0.49 0.55 928 Great Troll 0.20 0.03 0.05 146 Magnificent Troll 0.38 0.08 0.13 79 Quality Troll 0.29 0.63 0.40 347 avg / total 0.50 0.45 0.44 1500 ``` Malheureusement, le résultat n'est pas bon. Encore une fois, le modèle ne peut pas s'adapter à des données inconnues. Il s'agit d'un cas assez probant de [surapprentissage](https://fr.wikipedia.org/wiki/Surapprentissage). L'image suivante illustre bien le problème. En cherchant à classer correctement les éléments dans la bonne catégorie, le modèle se contorsionne et ne tient pas compte de la tendance "globale". ![overfitting](https://upload.wikimedia.org/wikipedia/commons/1/19/Overfitting.svg) # Utiliser des propriétés multiples Bien qu'elle soit informative, l'analyse ne permet pas de prédire la catégorie avec un score supérieur à 0,5. Pour l'instant, le classificateur se comporte comme un mauvais élève pressé d'aller jouer un match de tennis après son examen [Q.C.M.](https://fr.wikipedia.org/wiki/Questionnaire_%C3%A0_choix_multiples) : il répond la même chose (la réponse D) à toutes les questions en se disant qu'il obtiendra bien la moitié. Évidemment, cela ne fonctionne pas. L'approche "bag of words" seule ne suffit pas pour classer des journaux. Les bons journaux ne sont pas tous techniques, de même que les mauvais ne sont pas tous "politiques" (quoiqu'un journal sur l'avortement part en général très mal). Le sujet d'un journal n'est pas corrélé avec sa note finale. D'autres indicateurs doivent être pris en compte : ancienneté du compte au moment de la soumission, taille du texte (les journaux trop courts sont parfois descendu, tout comme les 'journaux fleuve parfois hallucinés' dixit [oumph](https://linuxfr.org/users/oumph)). scikit-learn permet de combiner plusieurs propriétés (appelées "features"), de déterminer celles qui ont le plus gros impact sur les résultats et d'ajuster un modèle en tenant compte des propriétés sélectionnées. ## Extraction et préparation des données L'analyse suivante repose sur l'utilisation des données présentes dans le fichier `linuxfr_complete.csv`. Elle correspond au notebook [diaries_classification_2.ipynb](https://gitlab.com/jnanar/scikit-linuxfr/blob/master/diaries_classification_2.ipynb). En plus des données présentes dans le fichier `linuxfr.csv`, ce document comporte les champs suivant : * la date de création du journal ; * la date de création du compte ; * les scores précédents de l'auteur (première page des anciennes publications) ; * la longueur du document. ```python import matplotlib.pyplot as plt import pandas as pd import numpy as np import sys import matplotlib # Enable inline plotting %matplotlib inline filename = r'linuxfr_complete.csv' lf_data = pd.read_csv(filename, encoding="UTF-8", sep='£', engine='python', quotechar='μ') ``` ## Conversion des dates Panda permet très facilement de convertir une chaîne de caractère correspondant à une date au format `datetime`. ```python lf_data['birthday'] = pd.to_datetime(lf_data['birthday']) lf_data['birthday'].head() 0 2004年08月28日 1 2003年04月22日 2 2004年02月14日 3 2012年10月22日 4 2009年10月05日 Name: birthday, dtype: datetime64[ns] ``` ```python lf_data['datetime'] = pd.to_datetime(lf_data['datetime']) lf_data['datetime'].head() 0 2017年05月28日 12:59:46 1 2017年05月28日 09:57:04 2 2017年05月28日 08:24:57 3 2017年05月27日 14:18:10 4 2017年05月26日 20:12:47 Name: datetime, dtype: datetime64[ns] ``` ## Évolution du score des journaux au fil du temps ```python score_df = lf_data[['datetime', 'score']].copy() score_df.index = score_df['datetime'] del score_df['datetime'] ``` L'évolution des scores au fil du temps est alors facilement affichable. Je trouve personnellement qu'on n'a pas trop à se plaindre : la qualité générale des journaux est plutôt bonne. ```python score_df.plot(marker='o', grid=True, figsize=(15,9)) plt.ylabel('Score', fontsize='xx-large') plt.yticks(fontsize='xx-large') plt.xlabel('Date', fontsize='xx-large') plt.xticks(fontsize='xx-large') ``` ![Date time linuxfr_complete.csv](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/datetime_2.png) ## Calcul de l’âge d’un compte L'âge d'un compte peut facilement être calculé en soustrayant la date de création du compte à la date de création du journal. Pour une raison inconnue, cet âge est parfois négatif. Le code suivant tient compte de ce souci. Un compte qui a moins d'un jour se voit affublé de la propriété "Newbie". ```python lf_data['age'] = lf_data['datetime']-lf_data['birthday'] lf_data['newbie'] = False ``` ```python for index, line in lf_data.iterrows(): # Problem: sometimes, age << 0 if line['age'] < pd.Timedelta("0 day"): line['age'] = - line['age'] if line['age'] < pd.Timedelta("1 day"): lf_data.set_value(index, 'newbie', True) ``` ### Qualité des posts des nouveaux Il est à présent possible d'extraire les informations relatives aux nouveaux comptes (à la date de publication). Ces comptes sont à l'origine de contenu de qualité étonnante. On retrouve une grande quantité de très mauvais contenu ("magnificent troll") mais également de bons et très bons contenus ("quality troll" et "average troll"). ```python noob = lf_data.loc[lf_data['newbie'] == True] noob.quality_content.value_counts().plot(kind='bar') plt.ylabel('Occurences', fontsize='xx-large') plt.yticks(fontsize='xx-large') plt.xlabel('Trolls', fontsize='xx-large') plt.xticks(fontsize='xx-large') ``` ![Journaux des nouveaux](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/newbies_2.png) ## Calcul de la moyenne des scores précédents Afin de tenir compte de l'historique d'un compte, deux colonnes sont ajoutées : la médiane et la moyenne. Les scores précédents sont conservés dans la colonne `author_previous_scores`. L'information, une chaîne de caractère sous la forme `"[1,15,42,-12]"`, doit être extraite. ```python lf_data['median_score'] = 0 lf_data['average_score'] = 0 import statistics for index, line in lf_data.iterrows(): ps = line['author_previous_scores'] #print(ps) ps = ps.replace("[",'') ps = ps.replace("]",'') ps = ps.replace(",",'') ps = ps.split() ps = [float(x) for x in ps] median = statistics.median(ps) try: avg = statistics.mean(ps) except TypeError: avg = np.NaN lf_data.set_value(index, 'median_score', median) lf_data.set_value(index, 'average_score', avg) ``` La plupart du temps, la médiane et la moyenne sont très proches. Dans de rares cas, elles diffèrent beaucoup mais la moyenne est plus sévère que la médiane. ## Garder l’essentiel Les informations nécessaires pour réaliser une analyse plus complète sont à présent disponibles. Pour plus de facilité, de nouveaux _dataframes_ sont créés en éliminant les colonnes inutiles. ```python lf = lf_data[['content','newbie','average_score', 'quality_content', 'score', 'count', 'author']].copy() target = lf_data[['quality_content']].copy() ``` # L’union fait la force Maintenant que je dispose d'un dataframe contenant mes variables (`lf`) et un autre contenant mes catégories attendues (`target`), il faut que je crée une procédure permettant d'effectuer les bonnes tâches avec le bon jeu de données : 1. Les données numériques sont utilisées telles quelles. 2. Le corps de l'article est vectorisé et la fréquence des mots est calculée. 3. Le nom de l'auteur est vectorisé également. Ces trois étapes sont unies dans un object `FeatureUnion` dans un pipeline dont la dernière étape est un classificateur de type linéaire (`SVC(kernel='linear')`). Encore une fois, les fonctions `.fit` et `.predict` sont accessibles depuis le _pipeline_ pour faciliter son utilisation. La classe `MultipleItemSelector` permet d'extraire les données nécessaires à chaque étape. Enfin, un poids est appliqué à chaque étape. Pour l'instant, il est égal sur les trois étapes mais des valeurs différentes ont donné des résultats similaires : * 'author': 0.8 ; * 'content': 0.5 ; * 'num_values': 1.0. ```python # From http://scikit-learn.org/stable/auto_examples/hetero_feature_union.html import numpy as np from sklearn.base import BaseEstimator, TransformerMixin from sklearn.datasets import fetch_20newsgroups from sklearn.datasets.twenty_newsgroups import strip_newsgroup_footer from sklearn.datasets.twenty_newsgroups import strip_newsgroup_quoting from sklearn.decomposition import TruncatedSVD from sklearn.feature_extraction import DictVectorizer from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics import classification_report from sklearn.pipeline import FeatureUnion from sklearn.pipeline import Pipeline from sklearn.svm import SVC class MultipleItemSelector(BaseEstimator, TransformerMixin): def __init__(self, keys): self.keys = keys def fit(self, x, y=None): return self def transform(self, data_dict): return data_dict[self.keys] pipeline = Pipeline([ # Extract the subject & body #('subjectbody', DataExtractor()), # Use FeatureUnion to combine the features from subject and body ('union', FeatureUnion( transformer_list=[ # Pipeline for pulling features from the post's subject line ('author', Pipeline([ ('selector', MultipleItemSelector(keys='author')), ('tfidf', TfidfVectorizer(min_df=50)), ])), # Pipeline for standard bag-of-words model for body ('content', Pipeline([ ('selector', MultipleItemSelector(keys='content')), ('tfidf', TfidfVectorizer()), ('best', TruncatedSVD(n_components=50)), ])), # Pipeline dealing with numerical values stored in a dict ('num_values', Pipeline([ ('selector', MultipleItemSelector(keys=['score', 'newbie', 'average_score', 'count'])) , # list of dicts -> feature matrix ])), ], # weight components in FeatureUnion transformer_weights={ 'author': 1.0, # 0.8 'content': 1.0, # 0.5 'num_values': 1.0, # 1.0 }, )), # Use a SVC classifier on the combined features ('svc', SVC(kernel='linear')), ]) #pipeline.fit(lf, target.values.ravel()) ``` ## Validation croisée Afin de valider le comportement du classificateur, la validation croisée est effectuée avec 10 échantillons. Cette fois, les résultats sont vraiment très bons. ```python from sklearn.model_selection import cross_val_score scores = cross_val_score(pipeline, # steps to convert raw messages into models lf, # training data target.values.ravel(), # training labels cv=10, # split data randomly into 10 parts: 9 for training, 1 for scoring #scoring='accuracy', # which scoring metric? scoring='f1_weighted', n_jobs=-1, # -1 = use all cores = faster ) print(scores) print('Total diaries classified:', len(lf_data)) print('Score:', sum(scores)/len(scores)) [ 1. 1. 1. 1. 1. 1. 1. 0.99831646 0.99831646 1. ] Total diaries classified: 5955 Score: 0.999663292104 ``` Afin de valider le comportement exceptionnel de ce classificateur, un test est réalisé avec des données qu'il ne connaît pas du tout : des journaux plus anciens. # Données hors échantillon. La prédiction à l'aide de journaux anciens permet de vérifier que le modèle se comporte bien. Si c'est le cas, cela permet également d'affirmer que le type de contenu pertinent/inutile n'a pas radicalement changé ces dernières années. Les données analysées dans cette section correspondent au fichier `out_of_sample_complete.csv`. La liste des journaux est sensiblement la même que celle utilisée dans le paragraphe [Test sur un échantillon de données inconnues](#test-sur-un-%C3%A9chantillon-de-donn%C3%A9es-inconnues). Les images ci-dessous montrent que la distribution temporelle de scores est similaire aux données plus récentes. La matrice de confusion et le score confirment les résultats obtenus à l'aide de la validation croisée. Le but est atteint. **Il est possible de prédire la catégorie dans laquelle se trouve un journal à partir de son contenu et du nom de l'auteur**. [^1] ![Répartition des données hors échantillon](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/out_of_sample_2.png) ![Scores en fonction du temps des données hors échantillon](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/datetime_out_2.png) ![Matrice confusion hors échantillon](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/mat_2_out.png) ![Matrice confusion normée hors échantillon](https://gitlab.com/jnanar/scikit-linuxfr/raw/master/images/mat_norm_2_out.png) ```python Diaries: 1485 Score: 1.0 ```` ```python from sklearn import metrics print(metrics.classification_report(Y_out, predicted_out, target_names=targets_names)) precision recall f1-score support Average Troll 1.00 1.00 1.00 917 Great Troll 1.00 1.00 1.00 145 Magnificent Troll 1.00 1.00 1.00 77 Quality Troll 1.00 1.00 1.00 346 avg / total 1.00 1.00 1.00 1485 ``` # Pour aller plus loin Scikit-learn dispose de nombreuses autres possibilités pour traiter des données de tout type. Je citerai la [sélection des propriétés](http://scikit-learn.org/stable/modules/feature_selection.html) (_feature selection_) qui permet d’éliminer les propriétés dont la variance est inférieure à un seuil donné. Dans ce cas, la propriété est considérée comme une constante. L'intérêt est de diminuer le temps de calcul et le risque de sur-apprentissage. Un test rapide sur les données issues des journaux a montré que toutes les données sont utiles pour déterminer la catégorie d'un journal. Choisir le bon algorithme peut être très difficile selon les informations désirées et le type de données. L'aide-mémoire suivant permet de faciliter ce choix. [Une version interactive est également disponible](http://scikit-learn.org/stable/tutorial/machine_learning_map/). ![aide-mémoire](http://scikit-learn.org/stable/_static/ml_map.png) Enfin, je terminerai en mentionnant la possibilité de [sauver un modèle entraîné](http://scikit-learn.org/stable/modules/model_persistence.html). Cela permet d'éviter de devoir repasser par l'opération d'apprentissage qui peut être consommatrice de ressources. Le fichier généré pour le modèle le plus efficace de cette dépêche fait 46 Mo. ```python from sklearn.externals import joblib joblib.dump(pipeline, 'linuxfr_pipeline.pkl') ``` Plus tard ou sur une autre machine: ```python from sklearn.externals import joblib pipeline = joblib.load('linuxfr_pipeline.pkl') ``` La [documentation](http://scikit-learn.org/stable/modules/model_persistence.html) mentionne des considérations à prendre en compte. * Il existe un risque de sécurité dans le cas où on charge des données car cela pourrait mener à l'exécution de code malicieux ; * la comptabilité entre versions n'est pas prise en compte ; * il est important de laisser à disposition un jeu de données types, le score de validation du modèle associé et le code afin que la personne qui reçoit le modèle puisse vérifier les résultats obtenus. # Conclusions Au cours de cette expérience, une [moulinette](https://gitlab.com/jnanar/scikit-linuxfr/blob/master/linuxfr_parser.py) a été codée afin d'aspirer le contenu des journaux. Les données ont été analysées en deux temps. Dans la première phase, la vectorisation du contenu des journaux a été réalisée. Il a été montré que cette étape ne suffit pas à pouvoir classer correctement du contenu inconnu. Dans une seconde phase, le nom de l'auteur, la date de publication, la date de création du compte, l'historique récente des scores des publications de l'auteur ont été pris en compte et assemblés dans un _pipeline_ d'analyse. Les résultats ont montré qu'**il est possible de prédire la catégorie dans laquelle se trouve un journal à partir de son contenu et du nom de l'auteur.** Le taux d'erreur est inférieur à 1/1000. Par ailleurs, l'optimisation des paramètres des classificateurs ainsi que la validation croisées ont été présentées. L'analyse prédictive des scores permettra plusieurs grandes avancées sur le site. La première et la plus évidente sera la possibilité de renvoyer un lien vers cette dépêche chaque fois qu'un contributeur se plaindra de la note réservée à sa prose. Il s'agit là de l'argument ultime qui ne manquera pas de faire taire les trolls devant tant d'autorité. La seconde avancée sera la possibilité pour un contributeur d'améliorer ses journaux afin d'atteindre à chaque fois la catégorie visée. Provoquer un séisme de moinsage ou atteindre le summum de l'excellence ne s'improvise pas et scikit-learn permettra à chacun d'évaluer différentes variantes de ses journaux afin de poster la "meilleure". La troisième avancée concerne les journaux qui ne peuvent pas avoir été élaboré par un esprit humain. [Ils sont probablement générés par une machine](https://en.wikipedia.org/wiki/Markov_chain#Markov_text_generators). Les plaisantins pourront améliorer les textes de leur programme en utilisant le modèle présenté afin de rendre la lecture du contenu plus agréable. La note finale s'en ressentira. Pour terminer, je dirai qu'il est possible de rapidement effectuer des analyses de données avec scikit-learn. La syntaxe est très simple pour les personnes connaissant Python. Les concepts sont assez compliqués, mais la mise en œuvre est très bien faite et la documentation officielle est complète. __Mais__, tous ces éléments positifs ne garantissent pas des résultats probants et immédiats. Comme dans toute matière complexe, il faut comprendre ce qu'on fait pour obtenir des résultats qui ont du sens (et être en mesure de les analyser). # Perspectives Plusieurs pistes de réflexion pourront permettre de poursuivre l'analyse : * Le découpage des catégories est arbitraire (bornes -20 et + 20) ; en modifiant la répartition des données, les résultats seront probablement différents (exemples : répartitions en quartiles, score strictement positifs ou négatifs, etc.) ; * au vu des résultats obtenus, la prédiction du score (valeur numérique) est envisageable ; * les catégories ne sont pas équitablement peuplées, le nombre de journaux à score négatif est beaucoup plus faible dans ce cas ; pour y remédier, nous avons besoin de plus de journaux de mauvaise qualité abondamment moinssés. À vos claviers ! * La classification par un système d'arbre en limitant leur profondeur n'a pas été testée ; * les paramètres du meilleur [_pipeline_](#lunion-fait-la-force) n'ont pas été optimisés. # Rêvons un peu Un éventail se possibilités s'ouvre à la communauté LinuxFR.org. La liste ci-dessous reprend les éléments qui me viennent en premier à l'esprit. * Réaliser une analyse temporelle des scores pour prédire la note d'un journal à venir en tenant compte de l'historique de publication général (comme la bourse) ; * prédire la note d'un commentaire (après avoir modifié la moulinette) ; * modifier le modèle afin de prédire le nombre de commentaires. Les contributeurs pourraient alors toucher du clavier la recette permettant de créer les trolls ultimes qui permettraient d'atteindre des sommets d'excellence, de courtoisie et de bienveillance dans une avalanche de remarques plus palpitantes les unes que les autres ; * ... N'hésitez pas à partager dans les commentaires vos suggestions, vos impressions et vos idées innovantes ! De même, le dépôt [gitlab](https://gitlab.com/jnanar/scikit-linuxfr) est accessible. Je vous invite à tester vos recettes sur les données présentes et à les exposer dans un journal ou une dépêche. Les différences entre les modèles peuvent être difficiles à appréhender. Toute explication complémentaire sera la bienvenue. # Note [^1]: Les scores précédents et la date de création du score peuvent facilement être déduite à l'aide du nom de l'auteur.

AltStyle によって変換されたページ (->オリジナル) /