Klassifizierung von Textdokumenten mit dünn besetzten Merkmalen#

Dies ist ein Beispiel, das zeigt, wie scikit-learn zur Klassifizierung von Dokumenten nach Themen mit einem Bag-of-Words-Ansatz verwendet werden kann. Dieses Beispiel verwendet eine Tf-idf-gewichtete, dünn besetzte Dokument-Term-Matrix zur Kodierung der Merkmale und demonstriert verschiedene Klassifikatoren, die dünn besetzte Matrizen effizient verarbeiten können.

Für die Dokumentenanalyse über einen unüberwachten Lernansatz siehe das Skriptbeispiel Textdokumente mit k-means clustern.

# Authors: The scikit-learn developers
# SPDX-License-Identifier: BSD-3-Clause

Laden und Vektorisieren des 20 Newsgroups Textdatensatzes#

Wir definieren eine Funktion zum Laden von Daten aus dem 20 Newsgroups Textdatensatz, der rund 18.000 Newsgroups-Beiträge zu 20 Themen umfasst, aufgeteilt in zwei Teilmengen: eine zum Trainieren (oder Entwickeln) und die andere zum Testen (oder zur Leistungsbewertung). Beachten Sie, dass die Textstichproben standardmäßig einige Metadaten der Nachricht enthalten, wie z. B. 'headers', 'footers' (Signaturen) und 'quotes' aus anderen Beiträgen. Die Funktion fetch_20newsgroups akzeptiert daher einen Parameter namens remove, um zu versuchen, solche Informationen zu entfernen, die das Klassifizierungsproblem „zu einfach“ machen können. Dies geschieht mit einfachen Heuristiken, die weder perfekt noch standardmäßig sind, und ist daher standardmäßig deaktiviert.

from time import time

from sklearn.datasets import fetch_20newsgroups
from sklearn.feature_extraction.text import TfidfVectorizer

categories = [
    "alt.atheism",
    "talk.religion.misc",
    "comp.graphics",
    "sci.space",
]


def size_mb(docs):
    return sum(len(s.encode("utf-8")) for s in docs) / 1e6


def load_dataset(verbose=False, remove=()):
    """Load and vectorize the 20 newsgroups dataset."""

    data_train = fetch_20newsgroups(
        subset="train",
        categories=categories,
        shuffle=True,
        random_state=42,
        remove=remove,
    )

    data_test = fetch_20newsgroups(
        subset="test",
        categories=categories,
        shuffle=True,
        random_state=42,
        remove=remove,
    )

    # order of labels in `target_names` can be different from `categories`
    target_names = data_train.target_names

    # split target in a training set and a test set
    y_train, y_test = data_train.target, data_test.target

    # Extracting features from the training data using a sparse vectorizer
    t0 = time()
    vectorizer = TfidfVectorizer(
        sublinear_tf=True, max_df=0.5, min_df=5, stop_words="english"
    )
    X_train = vectorizer.fit_transform(data_train.data)
    duration_train = time() - t0

    # Extracting features from the test data using the same vectorizer
    t0 = time()
    X_test = vectorizer.transform(data_test.data)
    duration_test = time() - t0

    feature_names = vectorizer.get_feature_names_out()

    if verbose:
        # compute size of loaded data
        data_train_size_mb = size_mb(data_train.data)
        data_test_size_mb = size_mb(data_test.data)

        print(
            f"{len(data_train.data)} documents - "
            f"{data_train_size_mb:.2f}MB (training set)"
        )
        print(f"{len(data_test.data)} documents - {data_test_size_mb:.2f}MB (test set)")
        print(f"{len(target_names)} categories")
        print(
            f"vectorize training done in {duration_train:.3f}s "
            f"at {data_train_size_mb / duration_train:.3f}MB/s"
        )
        print(f"n_samples: {X_train.shape[0]}, n_features: {X_train.shape[1]}")
        print(
            f"vectorize testing done in {duration_test:.3f}s "
            f"at {data_test_size_mb / duration_test:.3f}MB/s"
        )
        print(f"n_samples: {X_test.shape[0]}, n_features: {X_test.shape[1]}")

    return X_train, X_test, y_train, y_test, feature_names, target_names

Analyse eines Bag-of-Words-Dokumentenklassifikators#

Wir trainieren nun zweimal einen Klassifikator, einmal auf den Textstichproben einschließlich Metadaten und einmal nach dem Entfernen der Metadaten. In beiden Fällen analysieren wir die Klassifizierungsfehler auf einem Testset anhand einer Konfusionsmatrix und untersuchen die Koeffizienten, die die Klassifizierungsfunktion der trainierten Modelle definieren.

Modell ohne Entfernen von Metadaten#

Wir beginnen mit der benutzerdefinierten Funktion load_dataset, um die Daten ohne Entfernen von Metadaten zu laden.

X_train, X_test, y_train, y_test, feature_names, target_names = load_dataset(
    verbose=True
)
2034 documents - 3.98MB (training set)
1353 documents - 2.87MB (test set)
4 categories
vectorize training done in 0.314s at 12.670MB/s
n_samples: 2034, n_features: 7831
vectorize testing done in 0.207s at 13.853MB/s
n_samples: 1353, n_features: 7831

Unser erstes Modell ist eine Instanz der Klasse RidgeClassifier. Dies ist ein lineares Klassifizierungsmodell, das den mittleren quadratischen Fehler auf {-1, 1} kodierten Zielwerten verwendet, einen für jede mögliche Klasse. Im Gegensatz zu LogisticRegression bietet RidgeClassifier keine probabilistischen Vorhersagen (keine Methode predict_proba), ist aber oft schneller zu trainieren.

from sklearn.linear_model import RidgeClassifier

clf = RidgeClassifier(tol=1e-2, solver="sparse_cg")
clf.fit(X_train, y_train)
pred = clf.predict(X_test)

Wir plotten die Konfusionsmatrix dieses Klassifikators, um Muster in den Klassifizierungsfehlern zu finden.

import matplotlib.pyplot as plt

from sklearn.metrics import ConfusionMatrixDisplay

fig, ax = plt.subplots(figsize=(10, 5))
ConfusionMatrixDisplay.from_predictions(y_test, pred, ax=ax)
ax.xaxis.set_ticklabels(target_names)
ax.yaxis.set_ticklabels(target_names)
_ = ax.set_title(
    f"Confusion Matrix for {clf.__class__.__name__}\non the original documents"
)
Confusion Matrix for RidgeClassifier on the original documents

Die Konfusionsmatrix hebt hervor, dass Dokumente der Klasse alt.atheism oft mit Dokumenten der Klasse talk.religion.misc verwechselt werden und umgekehrt, was zu erwarten ist, da die Themen semantisch verwandt sind.

Wir beobachten auch, dass einige Dokumente der Klasse sci.space fälschlicherweise als comp.graphics klassifiziert werden können, während das Gegenteil viel seltener vorkommt. Eine manuelle Inspektion dieser falsch klassifizierten Dokumente wäre erforderlich, um Einblicke in diese Asymmetrie zu gewinnen. Es könnte sein, dass der Wortschatz des Weltraumthemas spezifischer ist als der Wortschatz für Computergrafik.

Wir können ein tieferes Verständnis dafür gewinnen, wie dieser Klassifikator seine Entscheidungen trifft, indem wir uns die Wörter mit den höchsten durchschnittlichen Effekten ansehen

import numpy as np
import pandas as pd


def plot_feature_effects():
    # learned coefficients weighted by frequency of appearance
    average_feature_effects = clf.coef_ * np.asarray(X_train.mean(axis=0)).ravel()

    for i, label in enumerate(target_names):
        top5 = np.argsort(average_feature_effects[i])[-5:][::-1]
        if i == 0:
            top = pd.DataFrame(feature_names[top5], columns=[label])
            top_indices = top5
        else:
            top[label] = feature_names[top5]
            top_indices = np.concatenate((top_indices, top5), axis=None)
    top_indices = np.unique(top_indices)
    predictive_words = feature_names[top_indices]

    # plot feature effects
    bar_size = 0.25
    padding = 0.75
    y_locs = np.arange(len(top_indices)) * (4 * bar_size + padding)

    fig, ax = plt.subplots(figsize=(10, 8))
    for i, label in enumerate(target_names):
        ax.barh(
            y_locs + (i - 2) * bar_size,
            average_feature_effects[i, top_indices],
            height=bar_size,
            label=label,
        )
    ax.set(
        yticks=y_locs,
        yticklabels=predictive_words,
        ylim=[
            0 - 4 * bar_size,
            len(top_indices) * (4 * bar_size + padding) - 4 * bar_size,
        ],
    )
    ax.legend(loc="lower right")

    print("top 5 keywords per class:")
    print(top)

    return ax


_ = plot_feature_effects().set_title("Average feature effect on the original data")
Average feature effect on the original data
top 5 keywords per class:
  alt.atheism comp.graphics sci.space talk.religion.misc
0       keith      graphics     space          christian
1         god    university      nasa                com
2    atheists        thanks     orbit                god
3      people          does      moon           morality
4     caltech         image    access             people

Wir können beobachten, dass die vorhersagestärksten Wörter oft stark positiv mit einer einzelnen Klasse und negativ mit allen anderen Klassen assoziiert sind. Die meisten dieser positiven Assoziationen sind recht einfach zu interpretieren. Einige Wörter wie "god" und "people" sind jedoch sowohl mit "talk.misc.religion" als auch mit "alt.atheism" positiv assoziiert, da diese beiden Klassen erwartungsgemäß einige gemeinsame Vokabulare teilen. Beachten Sie jedoch, dass es auch Wörter wie "christian" und "morality" gibt, die nur mit "talk.misc.religion" positiv assoziiert sind. Darüber hinaus ist in dieser Version des Datensatzes das Wort "caltech" eines der Top-Vorhersagemerkmale für Atheismus aufgrund von Verschmutzungen im Datensatz, die von Metadaten stammen, wie z. B. den E-Mail-Adressen des Absenders früherer E-Mails in der Diskussion, wie unten zu sehen ist.

data_train = fetch_20newsgroups(
    subset="train", categories=categories, shuffle=True, random_state=42
)

for doc in data_train.data:
    if "caltech" in doc:
        print(doc)
        break
From: livesey@solntze.wpd.sgi.com (Jon Livesey)
Subject: Re: Morality? (was Re: <Political Atheists?)
Organization: sgi
Lines: 93
Distribution: world
NNTP-Posting-Host: solntze.wpd.sgi.com

In article <1qlettINN8oi@gap.caltech.edu>, keith@cco.caltech.edu (Keith Allan Schneider) writes:
|> livesey@solntze.wpd.sgi.com (Jon Livesey) writes:
|>
|> >>>Explain to me
|> >>>how instinctive acts can be moral acts, and I am happy to listen.
|> >>For example, if it were instinctive not to murder...
|> >
|> >Then not murdering would have no moral significance, since there
|> >would be nothing voluntary about it.
|>
|> See, there you go again, saying that a moral act is only significant
|> if it is "voluntary."  Why do you think this?

If you force me to do something, am I morally responsible for it?

|>
|> And anyway, humans have the ability to disregard some of their instincts.

Well, make up your mind.    Is it to be "instinctive not to murder"
or not?

|>
|> >>So, only intelligent beings can be moral, even if the bahavior of other
|> >>beings mimics theirs?
|> >
|> >You are starting to get the point.  Mimicry is not necessarily the
|> >same as the action being imitated.  A Parrot saying "Pretty Polly"
|> >isn't necessarily commenting on the pulchritude of Polly.
|>
|> You are attaching too many things to the term "moral," I think.
|> Let's try this:  is it "good" that animals of the same species
|> don't kill each other.  Or, do you think this is right?

It's not even correct.    Animals of the same species do kill
one another.

|>
|> Or do you think that animals are machines, and that nothing they do
|> is either right nor wrong?

Sigh.   I wonder how many times we have been round this loop.

I think that instinctive bahaviour has no moral significance.
I am quite prepared to believe that higher animals, such as
primates, have the beginnings of a moral sense, since they seem
to exhibit self-awareness.

|>
|>
|> >>Animals of the same species could kill each other arbitarily, but
|> >>they don't.
|> >
|> >They do.  I and other posters have given you many examples of exactly
|> >this, but you seem to have a very short memory.
|>
|> Those weren't arbitrary killings.  They were slayings related to some
|> sort of mating ritual or whatnot.

So what?     Are you trying to say that some killing in animals
has a moral significance and some does not?   Is this your
natural morality>


|>
|> >>Are you trying to say that this isn't an act of morality because
|> >>most animals aren't intelligent enough to think like we do?
|> >
|> >I'm saying:
|> >    "There must be the possibility that the organism - it's not
|> >    just people we are talking about - can consider alternatives."
|> >
|> >It's right there in the posting you are replying to.
|>
|> Yes it was, but I still don't understand your distinctions.  What
|> do you mean by "consider?"  Can a small child be moral?  How about
|> a gorilla?  A dolphin?  A platypus?  Where is the line drawn?  Does
|> the being need to be self aware?

Are you blind?   What do you think that this sentence means?

        "There must be the possibility that the organism - it's not
        just people we are talking about - can consider alternatives."

What would that imply?

|>
|> What *do* you call the mechanism which seems to prevent animals of
|> the same species from (arbitrarily) killing each other?  Don't
|> you find the fact that they don't at all significant?

I find the fact that they do to be significant.

jon.

Solche Kopfzeilen, Signaturfußzeilen (und zitierte Metadaten aus früheren Nachrichten) können als Nebeninformationen betrachtet werden, die die Newsgroups künstlich aufdecken, indem sie die registrierten Mitglieder identifizieren, und man möchte lieber, dass unser Textklassifikator nur aus dem „Hauptinhalt“ jedes Textdokuments lernt, anstatt sich auf die durchgesickerte Identität der Schreiber zu verlassen.

Modell mit Entfernen von Metadaten#

Die Option remove des 20 Newsgroups-Datensatzladers in scikit-learn ermöglicht den heuristischen Versuch, einige dieser unerwünschten Metadaten herauszufiltern, die das Klassifizierungsproblem künstlich erleichtern. Seien Sie sich bewusst, dass ein solches Filtern der Textinhalte alles andere als perfekt ist.

Lassen Sie uns versuchen, diese Option zu nutzen, um einen Textklassifikator zu trainieren, der sich nicht zu sehr auf diese Art von Metadaten verlässt, um seine Entscheidungen zu treffen.

(
    X_train,
    X_test,
    y_train,
    y_test,
    feature_names,
    target_names,
) = load_dataset(remove=("headers", "footers", "quotes"))

clf = RidgeClassifier(tol=1e-2, solver="sparse_cg")
clf.fit(X_train, y_train)
pred = clf.predict(X_test)

fig, ax = plt.subplots(figsize=(10, 5))
ConfusionMatrixDisplay.from_predictions(y_test, pred, ax=ax)
ax.xaxis.set_ticklabels(target_names)
ax.yaxis.set_ticklabels(target_names)
_ = ax.set_title(
    f"Confusion Matrix for {clf.__class__.__name__}\non filtered documents"
)
Confusion Matrix for RidgeClassifier on filtered documents

Wenn wir uns die Konfusionsmatrix ansehen, wird deutlicher, dass die Werte des mit Metadaten trainierten Modells überoptimistisch waren. Das Klassifizierungsproblem ohne Zugriff auf die Metadaten ist weniger genau, aber repräsentativer für das beabsichtigte Textklassifizierungsproblem.

_ = plot_feature_effects().set_title("Average feature effects on filtered documents")
Average feature effects on filtered documents
top 5 keywords per class:
  alt.atheism comp.graphics sci.space talk.religion.misc
0         don      graphics     space                god
1      people          file      like          christian
2         say        thanks      nasa              jesus
3    religion         image     orbit         christians
4        post          does    launch              wrong

Im nächsten Abschnitt behalten wir den Datensatz ohne Metadaten, um mehrere Klassifikatoren zu vergleichen.

Benchmarking von Klassifikatoren#

Scikit-learn bietet viele verschiedene Arten von Klassifizierungsalgorithmen. In diesem Abschnitt trainieren wir eine Auswahl dieser Klassifikatoren für dasselbe Textklassifizierungsproblem und messen sowohl ihre Generalisierungsleistung (Genauigkeit auf dem Testset) als auch ihre Rechenleistung (Geschwindigkeit), sowohl beim Training als auch beim Testen. Zu diesem Zweck definieren wir die folgenden Benchmarking-Dienstprogramme.

from sklearn import metrics
from sklearn.utils.extmath import density


def benchmark(clf, custom_name=False):
    print("_" * 80)
    print("Training: ")
    print(clf)
    t0 = time()
    clf.fit(X_train, y_train)
    train_time = time() - t0
    print(f"train time: {train_time:.3}s")

    t0 = time()
    pred = clf.predict(X_test)
    test_time = time() - t0
    print(f"test time:  {test_time:.3}s")

    score = metrics.accuracy_score(y_test, pred)
    print(f"accuracy:   {score:.3}")

    if hasattr(clf, "coef_"):
        print(f"dimensionality: {clf.coef_.shape[1]}")
        print(f"density: {density(clf.coef_)}")
        print()

    print()
    if custom_name:
        clf_descr = str(custom_name)
    else:
        clf_descr = clf.__class__.__name__
    return clf_descr, score, train_time, test_time

Wir trainieren und testen nun die Datensätze mit 8 verschiedenen Klassifikationsmodellen und erhalten Leistungsergebnisse für jedes Modell. Das Ziel dieser Studie ist es, die Kompromisse zwischen Rechenleistung und Genauigkeit verschiedener Klassifikatortypen für ein solches multiklasse Textklassifizierungsproblem hervorzuheben.

Beachten Sie, dass die wichtigsten Hyperparameterwerte mithilfe einer Grid-Search-Prozedur optimiert wurden, die der Einfachheit halber in diesem Notebook nicht gezeigt wird. Siehe das Beispielskript Beispiel-Pipeline für Textmerkmalextraktion und -auswertung für eine Demonstration, wie eine solche Abstimmung durchgeführt werden kann.

from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression, SGDClassifier
from sklearn.naive_bayes import ComplementNB
from sklearn.neighbors import KNeighborsClassifier, NearestCentroid
from sklearn.svm import LinearSVC

results = []
for clf, name in (
    (LogisticRegression(C=5, max_iter=1000), "Logistic Regression"),
    (RidgeClassifier(alpha=1.0, solver="sparse_cg"), "Ridge Classifier"),
    (KNeighborsClassifier(n_neighbors=100), "kNN"),
    (RandomForestClassifier(), "Random Forest"),
    # L2 penalty Linear SVC
    (LinearSVC(C=0.1, dual=False, max_iter=1000), "Linear SVC"),
    # L2 penalty Linear SGD
    (
        SGDClassifier(
            loss="log_loss", alpha=1e-4, n_iter_no_change=3, early_stopping=True
        ),
        "log-loss SGD",
    ),
    # NearestCentroid (aka Rocchio classifier)
    (NearestCentroid(), "NearestCentroid"),
    # Sparse naive Bayes classifier
    (ComplementNB(alpha=0.1), "Complement naive Bayes"),
):
    print("=" * 80)
    print(name)
    results.append(benchmark(clf, name))
================================================================================
Logistic Regression
________________________________________________________________________________
Training:
LogisticRegression(C=5, max_iter=1000)
train time: 0.163s
test time:  0.000581s
accuracy:   0.772
dimensionality: 5316
density: 1.0


================================================================================
Ridge Classifier
________________________________________________________________________________
Training:
RidgeClassifier(solver='sparse_cg')
train time: 0.0294s
test time:  0.000502s
accuracy:   0.76
dimensionality: 5316
density: 1.0


================================================================================
kNN
________________________________________________________________________________
Training:
KNeighborsClassifier(n_neighbors=100)
train time: 0.000724s
test time:  0.0332s
accuracy:   0.752

================================================================================
Random Forest
________________________________________________________________________________
Training:
RandomForestClassifier()
train time: 1.48s
test time:  0.0599s
accuracy:   0.7

================================================================================
Linear SVC
________________________________________________________________________________
Training:
LinearSVC(C=0.1, dual=False)
train time: 0.0275s
test time:  0.000475s
accuracy:   0.752
dimensionality: 5316
density: 1.0


================================================================================
log-loss SGD
________________________________________________________________________________
Training:
SGDClassifier(early_stopping=True, loss='log_loss', n_iter_no_change=3)
train time: 0.0291s
test time:  0.00053s
accuracy:   0.764
dimensionality: 5316
density: 1.0


================================================================================
NearestCentroid
________________________________________________________________________________
Training:
NearestCentroid()
train time: 0.148s
test time:  0.00149s
accuracy:   0.748

================================================================================
Complement naive Bayes
________________________________________________________________________________
Training:
ComplementNB(alpha=0.1)
train time: 0.0019s
test time:  0.000452s
accuracy:   0.779

Genauigkeit, Trainings- und Testzeit jedes Klassifikators plotten#

Die Streudiagramme zeigen den Kompromiss zwischen der Testgenauigkeit und der Trainings- und Testzeit jedes Klassifikators.

indices = np.arange(len(results))

results = [[x[i] for x in results] for i in range(4)]

clf_names, score, training_time, test_time = results
training_time = np.array(training_time)
test_time = np.array(test_time)

fig, ax1 = plt.subplots(figsize=(10, 8))
ax1.scatter(score, training_time, s=60)
ax1.set(
    title="Score-training time trade-off",
    yscale="log",
    xlabel="test accuracy",
    ylabel="training time (s)",
)
fig, ax2 = plt.subplots(figsize=(10, 8))
ax2.scatter(score, test_time, s=60)
ax2.set(
    title="Score-test time trade-off",
    yscale="log",
    xlabel="test accuracy",
    ylabel="test time (s)",
)

for i, txt in enumerate(clf_names):
    ax1.annotate(txt, (score[i], training_time[i]))
    ax2.annotate(txt, (score[i], test_time[i]))
  • Score-training time trade-off
  • Score-test time trade-off

Das Naive Bayes-Modell hat den besten Kompromiss zwischen Punktzahl und Trainings-/Testzeit, während Random Forest sowohl langsam zu trainieren, teuer in der Vorhersage als auch mit vergleichsweise schlechter Genauigkeit ist. Dies ist zu erwarten: Bei hochdimensionalen Vorhersageproblemen sind lineare Modelle oft besser geeignet, da die meisten Probleme linear trennbar werden, wenn der Merkmalsraum 10.000 Dimensionen oder mehr hat.

Der Unterschied in Trainingsgeschwindigkeit und Genauigkeit der linearen Modelle lässt sich durch die Wahl der Zielfunktion, die sie optimieren, und die Art der verwendeten Regularisierung erklären. Seien Sie sich bewusst, dass einige lineare Modelle mit demselben Verlust, aber einem anderen Solver oder einer anderen Regularisierungskonfiguration zu unterschiedlichen Anpassungszeiten und Testgenauigkeiten führen können. Wir können im zweiten Plot beobachten, dass nach dem Training alle linearen Modelle ungefähr die gleiche Vorhersagegeschwindigkeit haben, was zu erwarten ist, da sie alle die gleiche Vorhersagefunktion implementieren.

KNeighborsClassifier hat eine relativ geringe Genauigkeit und die höchste Testzeit. Die lange Vorhersagezeit ist ebenfalls zu erwarten: Bei jeder Vorhersage muss das Modell die paarweisen Distanzen zwischen der Teststichprobe und jedem Dokument im Trainingssatz berechnen, was rechenintensiv ist. Darüber hinaus schadet der „Fluch der Dimensionalität“ der Fähigkeit dieses Modells, in dem hochdimensionalen Merkmalsraum von Textklassifizierungsproblemen wettbewerbsfähige Genauigkeiten zu erzielen.

Gesamtlaufzeit des Skripts: (0 Minuten 6,043 Sekunden)

Verwandte Beispiele

Beispiel-Pipeline für Textmerkmal-Extraktion und -Bewertung

Beispiel-Pipeline für Textmerkmal-Extraktion und -Bewertung

Biclustering von Dokumenten mit dem Spectral Co-Clustering Algorithmus

Biclustering von Dokumenten mit dem Spectral Co-Clustering Algorithmus

Clustering von Textdokumenten mit K-Means

Clustering von Textdokumenten mit K-Means

FeatureHasher und DictVectorizer Vergleich

FeatureHasher und DictVectorizer Vergleich

Galerie generiert von Sphinx-Gallery