Prédire la note d’un journal sur LinuxFr.org

126
9
juin
2017
LinuxFr.org

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, 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. Un dépôt contenant les données et les scripts est disponible sur GitLab.

Sommaire

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 journaux 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. Le script 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 s'appuie sur la bibliothèque Beautiful Soup4. 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. 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.

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='µ')
len(lf_data)
    5921
lf_data.quality_content.value_counts()
    Average Troll         2614
    Quality Troll         2545
    Great Troll            460
    Magnificent Troll     302
    Name: quality_content, dtype: int64
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

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. L'analyse de texte est le plus souvent basée sur un algorithme de type "Bag of words". 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.

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 :

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

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.

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.

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.

diaries_test = lf_data.sample(frac=0.2)
predicted = text_clf.predict(diaries_test['quality_content'])
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) 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. Une image vaut mieux qu'un long discours, le dessin sur la page wikipedia :

précision et rappel

La 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
mat_NB_norm

Au passage, j'affiche la matrice de confusion à l'aide du code de la documentation officielle.

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 officielle, il s'agit de l’algorithme de classification de texte le plus performant pour le texte. SGDClassifier 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.

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 :

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
# 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
matrice de confusion 0.95 norm

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

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.

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
validation_croisée norm

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.

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 :

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 :

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

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 :

    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 :

gs_clf.best_estimator_
    Pipeline(steps=[('vect', CountVectorizer(analyzer='word', binary=False, decode_error='strict',
            dtype=<class 'numpy.int64'>, 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.

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
mat_grid_norm

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.

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

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
mat_out_norm

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

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

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]
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

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.

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

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

lf_data['age'] = lf_data['datetime']-lf_data['birthday']
lf_data['newbie'] = False
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").

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

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.

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.

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

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.

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
Scores en fonction du temps des données hors échantillon
Matrice confusion hors échantillon
Matrice confusion normée hors échantillon

Diaries: 1485
Score: 1.0
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 (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.
aide-mémoire

Enfin, je terminerai en mentionnant la possibilité de sauver un modèle entraîné. 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.

from sklearn.externals import joblib
joblib.dump(pipeline, 'linuxfr_pipeline.pkl')

Plus tard ou sur une autre machine:

from sklearn.externals import joblib
pipeline = joblib.load('linuxfr_pipeline.pkl')

La documentation 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 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. 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 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 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. 

  • # Merci aux contributeurs

    Posté par (page perso) . Évalué à 10 (+17/-0).

    Je voudrais remercier tous les contributeurs de la dépêche. C'est ma première dépêche je ne m'attendais pas à voir autant de contributions extérieures qui ont vraiment permis d'améliorer la qualité de l'analyse et du texte. DLFP est encore bien vivant! Merci !

  • # Prédiction

    Posté par . Évalué à 10 (+8/-0). Dernière modification le 09/06/17 à 09:50.

    Va falloir rajouter la classe "Über qualitaÿ troll of ze dead" pour cette dépèche :-)

    Un grand merci à tous les contributeurs pour ce bel article digne d'un Pierre Tramo !

    • [^] # Re: Prédiction

      Posté par (page perso) . Évalué à 7 (+5/-0).

      Oui, c'est vraiment un très bon article et manifestement un bien bon outil. Je te tire mon chapeau !

      It's a fez. I wear a fez now. Fezes are cool !

  • # Arrosage de l'arroseur

    Posté par . Évalué à 10 (+8/-0). Dernière modification le 09/06/17 à 10:03.

    Il sera intéressant, une fois que la note générale de ta dépêche sera stabilisée, de l'étudier récursivement selon les méthodes qu'elle expose.

    edit: s/ton journal/ta dépêche/

    • [^] # Re: Arrosage de l'arroseur

      Posté par (page perso) . Évalué à 10 (+11/-0).

      Je l'ai déjà fait sur le texte avant soumission. Elle était classée dans la catégorie "Great Troll" (-20 < n < 0). Contrairement aux journaux aspirés, le texte était au format markdown. L'absence de formatage HTML et la longueur du texte sont peut-être à l'origine de la classification. Ou alors, la cabale Linuxfr n'a pas encore moinsé. A suivre !

      • [^] # Re: Arrosage de l'arroseur

        Posté par . Évalué à 4 (+2/-0).

        Ta moulinette fonctionne pour les journaux, as-tu testé sur les dépêches… car la notation n’est forcément pas la même.

        • [^] # Re: Arrosage de l'arroseur

          Posté par (page perso) . Évalué à 3 (+2/-0).

          Non, j'ai entraîné le modèle uniquement sur les journaux. Je pense aussi que le comportement des votant est légèrement différent pour les dépêches. L'influence de la longueur du texte est aussi différente. Les dépêches moinsées sont en général plus courtes et le vocabulaire employé est plus typiques (contenu "commercial"). La toute grosse majorité des dépêches notées négativement est à -1. Il faudrait modifier la moulinette pour les analyser. Cela ne doit pas être très difficile. L'ancienneté du compte est peut-être moins cruciale (mis à part pour le contenu commercial).

          • [^] # Re: Arrosage de l'arroseur

            Posté par . Évalué à 5 (+2/-0).

            As-tu tenté les "digram" en gros les groupes de 2 mots voir plus ? Cela permet de trouver les expressions. Bien sûr, il faut virer les mots simples et les digram trop peu fréquent.

            Tu parles "d'extraction de feature" manuel, as-tu un mode automatique ? Le principe même du deep learning est de trouver ses fameuses features tout seul.

            As-tu tenté de prédire les notes des commentaires ? C'est souvent le plus problématique pour un site web.

            "La première sécurité est la liberté"

            • [^] # Re: Arrosage de l'arroseur

              Posté par (page perso) . Évalué à 2 (+1/-0).

              Oui, j'ai essayé de modifier le paramètre ngram_range=(1, 2) pour trouver des "unigrams" et des "digrams" dans mes premiers essais (première partie). Malheureusement, cela ne fonctionnait pas car la vectorisation du texte n'est pas suffisante. Il faudrait lancer une optimisation des paramètres sur le pipeline des propriétés jointes (partie 2). Comme les résultats étaient excellents avec les paramètres par défaut, je n'ai pas cherché à raffiner le score.

              Concernant l'extraction des propriétés, comme je suis dépendant de la moulinette, j'ai choisis manuellement des paramètres qui ont du sens afin de ne pas trop rallonger les temps de calculs. Avec la base de données complète, il serait intéressant de laisser le modèle se débrouiller pour trouver les paramètres clés. Il est possible de laisser le programme sélectionner les propriétés les plus intéressantes (celles qui ont le plus une incidence sur le score):
              http://scikit-learn.org/stable/modules/feature_selection.html

  • # async

    Posté par . Évalué à 4 (+2/-0).

    J'aurais bien étudié une moulinette en async/await :)

  • # Couplage avec IPoT

    Posté par (page perso) . Évalué à 10 (+10/-0).

    Avec IPoT + la moulinette, tu peux éviter d'éviter d'écrire les journaux qui seront mal notés pour te concentrer sur ceux qui seront bien notés. Sans parler de la discussion avec son soi futur pour parler de l'ancienneté du compte et des contenus/commentaires de mon futur et de son passé, tout ça pour extraite la quintessence de la DLFPtitude.

    • [^] # Re: Couplage avec IPoT

      Posté par . Évalué à 4 (+4/-0).

      Oui mais modifier ainsi le continuum espace temps modifierai également la base d’apprentissage et donc apporterai une sorte d'élitisme à ce classifieur; les great trolls glissant vers la catégorie magnificent trolls et ainsi de suite. Par application récursive, nous arriverions à une situation ou aucun journal ne serait publié avant d'atteindre une qualitaÿ parfaite !

      Nom de Zeus !

  • # Correction

    Posté par (page perso) . Évalué à 1 (+1/-1).

    •20 < n : Qualitaÿ Troll.

    Correction •20 > n : Qualitaÿ Troll.

  • # Moi, j'aime pas être analysé

    Posté par (page perso) . Évalué à 7 (+6/-0). Dernière modification le 09/06/17 à 14:57.

    Demain, je ne publie plus mes journaux que dans des images !!

    • [^] # Re: Moi, j'aime pas être analysé

      Posté par . Évalué à 4 (+2/-0). Dernière modification le 09/06/17 à 16:03.

      Faut que ça soit des images captcha hein, parce sinon on va juste passer tesseract sur tes journaux ^_^

    • [^] # Re: Moi, j'aime pas être analysé

      Posté par (page perso) . Évalué à 5 (+2/-0).

      Je pense que tu as eu un trouble dans ton enfance, en lien avec un décès violent des parents et un sentiment de culpabilité. Plus un penchant pour la violence, le cuir, les masques, et les chauve-souris (bien qu'il s'agisse aussi d'un traumatisme initial). Et une phobie de l'analyse apparemment.

  • # Record

    Posté par . Évalué à 9 (+7/-0).

    Excellent journal, hyper didactique, bravo.

    Ça m'a permis de m'assurer que je détiens toujours le records du journal le moins bien noté. Je relance le défi ! :D

    • [^] # Re: Record

      Posté par (page perso) . Évalué à 2 (+1/-0).

      Ça ne me viendrai même pas à l'idée de tenter :)

    • [^] # Re: Record

      Posté par . Évalué à 3 (+1/-0).

      Bravo, effectivement : -135 et -450 c’est des beaux scores… je suis sûr que même dopé, je n’arriverai pas à te battre. Par contre, tu peux essayer de te battre toi même, non ?

  • # L'emo me manque

    Posté par . Évalué à 6 (+5/-0).

    En un mot: wow. Autant ces derniers temps je lisais la plupart les journaux linux.fr d'un oeil lointain, autant je suis épaté par la qualité de celui-ci: le sujet, comment il à été traité, la clarté des explications -scikit.learn et les algos - et gitlab accessible avec son coulis de Jupyter.

    Bravo.

  • # Super !

    Posté par . Évalué à 4 (+1/-0).

    Bravo !

    J'avais l'idée de déterminer des niveaux de troll à partir de la forme des commentaires. Sans même en étudier le contenu, en prenant comme paramètres :

    • le nombre de commentaire
    • la profondeur de l'arbre
    • la fréquence des commentaires
    • la variance pertinent/inutile

    On doit pouvoir faire un détecteur de troll. Aujourd'hui l'image informant que tu es entrain de répondre à un troll vient d'une heuristique uniquement basée sur la profondeur (alors que des fois ça n'est pas du tout du troll).

    Tous les contenus que j'écris ici sont sous licence CC0 (j'abandonne autant que possible mes droits d'auteur sur mes écrits)

    • [^] # Re: Super !

      Posté par . Évalué à 3 (+0/-0). Dernière modification le 09/06/17 à 17:53.

      Hé ! J'ai perdu mon avatar !

      Tous les contenus que j'écris ici sont sous licence CC0 (j'abandonne autant que possible mes droits d'auteur sur mes écrits)

  • # Prédictions

    Posté par . Évalué à 10 (+11/-0).

    Je ne suis pas un pro, mais voici mes 2c.

    Ma petite expérience : ajoute un maximum de features. L'algo d'apprentissage se débrouillera tout seul pour donner de l'importance où non à la feature. Si ça commence à ramer, il existe plusieurs méthodes pour enlever les features inutiles (ou qui donne peu d'information) (va voir la doc de scikit :).
    Sois imaginatif pour les features. Tu peux partir sur des features que tu penses rentrer en compte dans la qualité d'un Troll. Mes idées, à la volée : % du nombre de retours à la ligne par rapport au nombre de mots (aération du texte), nombre d'images, le jour de la semaine de la publication…
    Je suis convaincu que tu pourrais obtenir une assez bonne qualité de classification avec uniquement les métadonnées.

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

    Tu trouveras de super explications un peu partout sur le net, voici la mienne en quelques lignes.
    Comme dans tout algo de Machine Learning, tu part d'un modèle construit à partir des features. Ce modèle est composé de plein de paramètres. Tu utilises ensuite un algorithme d'optimisation pour trouver les paramètres qui colle le mieux aux observations.

    Dans les SVM, le modèle est super simple. Pour chaque feature "A", tu lui attribues un paramètres "a". Ensuite tu les combines linéairement:
    valeur finale = a*A + b*B + c*C + d*D…
    Lorsque tu fais de la classification, la valeur finale te donne un pourcentage : la confiance en l'appartenance à une catégorie donnée. Si tu fais de la régression la valeur finale est la valeur que tu chercheras à prédire (le score du journal pour nous).

    En phase d'apprentissage, l'algo d'optimisation va chercher les valeurs des a,b,c,d… qui vont faire en sorte que les valeurs finales prédites soit la plus proche des valeurs finales observées.
    Une fois l'apprentissage fini, tu connais les a,b,c,d… Pour prédire une valeur il te suffit d’effectuer l'opération et de récupérer le résultat.

    Pour l'optimisation, on utilise une descente de gradient stochastique. Nous avons vu juste avant que l'algo d'optimisation cherche les meilleures valeurs des a,b,c,d… par rapport aux donnés que l'on connaît. Cet algo va donc chercher à minimiser l'erreur de prédiction commise. Elle va donc cherche à diminuer ("descente") l'erreur de prédiction, en faisant varier les paramètres. Ces paramètres, on ne le fait pas changer au hasard, mais en se rapprochant doucement ("gradient") des points les plus bas, en faisant des sauts de temps en temps ("stochastique").

    C'est très souvent ce genre d'algo d'optimisation utilisé, car ils fonctionnent super bien si le modèle à les bonnes propriétés mathématiques. Si tu n'as pas ces propriétés, il faut bourriner avec des variantes de recuit simulés (algo génétique, Monte Carlo…).

    • [^] # Re: Prédictions

      Posté par (page perso) . Évalué à 3 (+2/-0).

      Merci pour les explications, c'est un peu plus clair. Comme expliqué plus bas, il y a une erreur qui explique les scores aussi bons (la note est prise en compte dans les données de départ). Je vais tenter d'améliorer le modèle et d'augmenter le nombre de features.

  • # test/train

    Posté par (page perso) . Évalué à 3 (+2/-0).

    Salut

    C'est sympa scikit-learn, n'est-ce pas ?!

    Pas le temps de creuser, mais j'ai une remarque: tu différencies bien tes dataset d'entraînement et de test ? Ca fausse tous les scores, sinon. La routine test_train_split (ou ~) sera ton amie.

    Si le sujet du machine learning t'intéresse, je me permets de t'indiquer ce site : http://oceandata.io (le mien). Jouer avec les données, c'est mon gagne-pain.

  • # Mouais

    Posté par (page perso) . Évalué à 2 (+1/-0).

    Bon, j'ai lu.
    Pas jusqu'au bout, pas le temps.

    En effet, il y a parfois confusion test/train, ce qui donne des scores de fou … normal.

    De deux, je suis pas sur de la qualité du dataset de base. Faudrait voir s'il y a une qcq cohérence entre le contenu et la note. Fitter du bruit, ca doit donner du bruit. Ca manque cruellement de découverte/qualification des données d'entrées, padawan.

    Mais bel effort :)

    Et SGD != SVM.

    • [^] # Re: Mouais

      Posté par (page perso) . Évalué à 3 (+2/-0).

      Merci pour le retour et les conseils. Je pense que la procédure de calcul du score par la validation croisée sépare les données en train et test. Ensuite, le gros problème est dû à une faute d'inattention. Le pipeline qui unit les features tient compte du score. Je m'en suis rendu compte hier. Le système connaît la réponse, ce qui rend les prédictions triviales. Je cherche une solution pour obtenir de bons scores sans ça. Je ferai un erratum dans un journal. Je débute dans le domaine (moins d'un mois) et je voulais m'attaquer à un problème ludique plutôt que de suivre les tutoriels classiques. C'était aussi l'occasion de tenter de communiquer sur le sujet (de manière imparfaite) sur ce site.

      • [^] # Re: Mouais

        Posté par . Évalué à 5 (+2/-0).

        Le conseil du maximum de feature donné plus haut et bon je pense.

        Il y a longtemps j'avais fais un concours d'IA, un des participants utilisaient une IA généré par algo génétique. L'outil générait du code javascript. Le niveau de l'IA a été limité par un truc stupide : les entrées étaient trop réduites. Au début, il avait artificiellement fusionné/regroupé/trier des données. Ensuite, il était trop tard pour relancer toutes la génération.

        "La première sécurité est la liberté"

  • # Cool !

    Posté par . Évalué à 6 (+6/-0).

    Super sympa comme projet.

    Si tu cherches de la doc en ML plutôt sympa, je te recommande les vidéos Youtube d'Hugo Larochelle (en anglais et en français). Ce sont ses cours à l'université de Sherbrooke qui ont été filmés, et c'est ce vers quoi j'envoie mes étudiants curieux.

    Si tu fais du surapprentissage, rajouter des features ne va pas résoudre le problème, au contraire. Le sur-apprentissage peut venir soit d'un modèle trop complexe par rapport à la réalité (comme sur ton image), soit du fait que des données de train ne sont pas représentatives de l'univers (problème de sampling, donc). Dans ton cas, comme tu utilises un classifieur linéaire, c'est difficilement lui qui peut être mis en cause. De plus, sa complexité est directement liée à la dimension des features (théorie de Vapnik), et rajouter des features va amplifier le phénomène plutôt que le corriger.

    Donc, à mon avis, tu devrais plutôt t'orienter vers autre chose. D'abord t'assurer que tes données de train sont cohérentes avec celles de test. Ensuite, Tf/idf est particulièrement moche et surtout très bruité. Tu devrais pré-traiter tes vecteurs d'entrée pour supprimer les mots inutiles. Une PCA peut faire l'affaire, mais des outils de features selection feront sans doute mieux. Regarde aussi si tu peux mieux régulariser ton SVM, idéalement avec une norme l1 qui te permettra d'éliminer les mots (ou combinaisons de mots) non-informative.

    Tu devrais aussi regarder les outils de deep learning pour comparer. Il y a pleins de papiers intéressant sur des réseaux convolutionnels ou récurrents pour apprendre des représentations à partir d'un texte.

    En tout, encore bravo pour cette dépêche, c'est sympa de voir des gens faire ce genre de projet en dehors de projet académiques.

    • [^] # Re: Cool !

      Posté par (page perso) . Évalué à 2 (+1/-0).

      Merci pour ces commentaires. Je vais creuser le sujet et analyser les données plus en détail avant d'essayer d'ajuster des modèles. Les "pairplots" de seaborn sont instructifs (par le manque de corrélation). Les données sont très bruitées. En tout cas, la discrimination sur le score n'est pas évidente à partir de features extraites du texte. Je vais continuer de lire et apprendre sur le sujet pour mieux comprendre le sujet. Je note les références, mots clés et la références aux vidéos pour plus tard. Les discussion et recommandations qui découlent de la dépêche sont passionnantes.

Envoyer un commentaire

Suivre le flux des commentaires

Note : les commentaires appartiennent à ceux qui les ont postés. Nous n'en sommes pas responsables.