Tutorial (Japanese Text Classification)

Classify Japanese news articles with nagisa and scikit-learn

This tutorial provides an example of classifying Japanese news articles into categories by using nagisa for tokenization and scikit-learn for training a classifier. You convert the tokenized articles into TF-IDF vectors, train a logistic regression classifier, evaluate it and classify new texts.

Install python libraries

Before we get started, please run the following commands to install the libraries used in this tutorial.

pip install nagisa
pip install scikit-learn
pip install tqdm

Download the dataset

This tutorial uses the Livedoor News Corpus, which contains 7,367 Japanese news articles in 9 categories. The corpus (~9 MB) is downloaded automatically into the ldcc/ directory on the first run. Each document is the title and the body of an article, and its label is the category (the news site of the article).

Tokenize Japanese text with nagisa

tokenize() keeps the content words (nouns, verbs, adjectives, etc.) using nagisa.extract(), lowercases them and removes the stopwords in nagisa.stopwords. nagisa works on a sentence, so the text is tokenized line by line.

def tokenize(text):
    for name in SOURCE_NAMES:
        text = text.replace(name, " ")

    # nagisa works on a sentence, so tokenize the text line by line.
    words = []
    for line in text.splitlines():
        tokens = nagisa.extract(line, extract_postags=TARGET_POSTAGS)
        words += [word.lower() for word in tokens.words]
    return [word for word in words if word not in nagisa.stopwords]

Before splitting the text into words, tokenize() removes the names of the news sites in SOURCE_NAMES. Many articles contain the name of their site (e.g., “S-MAX” appears in 863 of the 870 articles of the smax category). Such words tell the category directly, so a classifier would learn the site names instead of the content of the articles.

Train and evaluate a classifier

The script splits the corpus into the train set (80%) and the test set (20%), keeping the ratio of the categories (stratify=labels). The vectorizer and the classifier are fitted on the train set only. TF-IDF gives a high value to a word that appears frequently in a document but rarely in the other documents. TfidfVectorizer receives the tokenized documents as they are (analyzer=identity) and ignores the words that appear in only one document (min_df=2). LogisticRegression learns a weight for each word and each category.

Run the following command. Tokenizing all articles takes several minutes.

python tutorial_text_classification.py
tutorial_text_classification.py
  1import io
  2import os
  3import tarfile
  4import urllib.request
  5
  6import nagisa
  7import numpy as np
  8from sklearn.feature_extraction.text import TfidfVectorizer
  9from sklearn.linear_model import LogisticRegression
 10from sklearn.metrics import accuracy_score, classification_report
 11from sklearn.model_selection import train_test_split
 12from tqdm import tqdm
 13
 14CORPUS_URL = "https://www.rondhuit.com/download/ldcc-20140209.tar.gz"
 15CORPUS_DIR = "ldcc"
 16
 17# Content words used as features
 18TARGET_POSTAGS = ["名詞", "動詞", "形容詞", "形状詞", "英単語"]
 19
 20# News site names, which tell the category directly
 21SOURCE_NAMES = ["独女通信", "ITライフハック", "家電チャンネル", "livedoor HOMME", "MOVIE ENTER",
 22                "Peachy", "S-MAX", "エスマックス", "smaxjp", "Sports Watch"]
 23
 24
 25def download_corpus():
 26    if os.path.isdir(CORPUS_DIR):
 27        return
 28
 29    with urllib.request.urlopen(CORPUS_URL, timeout=120) as resp:
 30        data = resp.read()
 31
 32    with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as tar:
 33        # The filter argument is not available in older Python versions.
 34        if hasattr(tarfile, "data_filter"):
 35            tar.extractall(path=".", filter="data")
 36        else:
 37            tar.extractall(path=".")
 38    os.rename("text", CORPUS_DIR)
 39
 40
 41def load_corpus():
 42    texts = []
 43    labels = []
 44    for category in sorted(os.listdir(CORPUS_DIR)):
 45        cat_path = os.path.join(CORPUS_DIR, category)
 46        if not os.path.isdir(cat_path):
 47            continue
 48        for fname in sorted(os.listdir(cat_path)):
 49            if not fname.endswith(".txt") or fname.startswith("LICENSE"):
 50                continue
 51            with open(os.path.join(cat_path, fname), encoding="utf-8") as f:
 52                lines = f.read().splitlines()
 53
 54            # line 1: URL, line 2: date, line 3: title, line 4-: body
 55            texts.append("\n".join(lines[2:]))
 56            labels.append(category)
 57    return texts, labels
 58
 59
 60def tokenize(text):
 61    for name in SOURCE_NAMES:
 62        text = text.replace(name, " ")
 63
 64    # nagisa works on a sentence, so tokenize the text line by line.
 65    words = []
 66    for line in text.splitlines():
 67        tokens = nagisa.extract(line, extract_postags=TARGET_POSTAGS)
 68        words += [word.lower() for word in tokens.words]
 69    return [word for word in words if word not in nagisa.stopwords]
 70
 71
 72def identity(tokens):
 73    # The documents are already tokenized.
 74    return tokens
 75
 76
 77def main():
 78    download_corpus()
 79    texts, labels = load_corpus()
 80    docs = [tokenize(text) for text in tqdm(texts)]
 81
 82    # Split the corpus into the train (80%) and test (20%) sets
 83    train_docs, test_docs, train_labels, test_labels = train_test_split(
 84        docs, labels, test_size=0.2, stratify=labels, random_state=1234)
 85
 86    # Convert the documents into TF-IDF vectors
 87    vectorizer = TfidfVectorizer(analyzer=identity, min_df=2)
 88    train_vecs = vectorizer.fit_transform(train_docs)
 89    test_vecs = vectorizer.transform(test_docs)
 90    print("train: {}, test: {}, features: {}".format(
 91        len(train_docs), len(test_docs), len(vectorizer.vocabulary_)))
 92
 93    # Train a classifier
 94    model = LogisticRegression(max_iter=1000)
 95    model.fit(train_vecs, train_labels)
 96
 97    # Evaluate the classifier on the test set
 98    pred_labels = model.predict(test_vecs)
 99    print("accuracy: {:.4f}".format(accuracy_score(test_labels, pred_labels)))
100    print(classification_report(test_labels, pred_labels, digits=3))
101
102    # Words with the largest weights for each category
103    words = vectorizer.get_feature_names_out()
104    for category, coef in zip(model.classes_, model.coef_):
105        top_words = [words[i] for i in np.argsort(-coef)[:8]]
106        print("{:<15} {}".format(category, ", ".join(top_words)))
107
108    # Classify new texts
109    new_texts = [
110        "新しいAndroidスマートフォンが発売され、アプリの動作も快適になった。",
111        "日本代表は昨日の試合で2対1で勝利した。",
112        "監督が新作映画の見どころを語った。",
113    ]
114    new_vecs = vectorizer.transform([tokenize(text) for text in new_texts])
115    for text, probs in zip(new_texts, model.predict_proba(new_vecs)):
116        best = np.argmax(probs)
117        print("\n{}\n  => {} ({:.2f})".format(text, model.classes_[best], probs[best]))
118
119
120if __name__ == "__main__":
121    main()

This is the output of tutorial_text_classification.py. The results may differ with other versions of the libraries (we used nagisa 0.3.0 and scikit-learn 1.9.1).

train: 5893, test: 1474, features: 34307
accuracy: 0.8996
                precision    recall  f1-score   support

dokujo-tsushin      0.855     0.845     0.850       174
  it-life-hack      0.918     0.897     0.907       174
 kaden-channel      0.948     0.942     0.945       173
livedoor-homme      0.934     0.696     0.798       102
   movie-enter      0.893     0.960     0.925       174
        peachy      0.785     0.822     0.803       169
          smax      0.923     0.971     0.947       174
  sports-watch      0.927     0.983     0.954       180
    topic-news      0.938     0.890     0.913       154

      accuracy                          0.900      1474
     macro avg      0.902     0.890     0.894      1474
  weighted avg      0.901     0.900     0.899      1474

dokujo-tsushin  独女, 女性, 女, 人, 結婚, 自分, 3, 男性
it-life-hack    孫, pc, 見る, 紺子, ロゴ, 製品, クリック, ter
kaden-channel   話題, 売れ筋, ビデオ, salon, チェック, 発売, パナソニック, hulu
livedoor-homme  ゴルフ, 転職, 求人, クルマ, ビジネス, 0, 年収, ドバイ
movie-enter     映画, 公開, 本作, 作品, 劇場, 演じる, 映像, dvd
peachy          女子, 女性, プレゼント, 恋愛, クリスマス, 事, 肌, 応募
smax            アプリ, on, android, 執筆, タップ, フォン, 画面, twitter
sports-watch    選手, 試合, 代表, サッカー, 日本, 野球, 五輪, ダルビッシュ
topic-news      掲示, ネット, 声, 韓国, 関連, 批判, 記事, 報じ

新しいAndroidスマートフォンが発売され、アプリの動作も快適になった。
  => smax (0.78)

日本代表は昨日の試合で2対1で勝利した。
  => sports-watch (0.88)

監督が新作映画の見どころを語った。
  => movie-enter (0.70)

livedoor-homme has the lowest recall (0.696). It has the fewest articles (511) and covers many topics such as cars, golf and jobs. peachy and dokujo-tsushin, which are both about women’s lifestyles, also have low F1 scores (0.803 and 0.850).

The words with the largest weights are mostly content words, such as 映画 and 公開 for movie-enter, and 選手 and 試合 for sports-watch. Some site-specific words remain: 独女 (a word used by dokujo-tsushin), 紺子 (a column character in it-life-hack), and 執筆 and on (from “記事執筆:…” and “… smaxjp on Twitter” in the smax articles).

Without removing SOURCE_NAMES, the accuracy is 0.904, but site names such as peachy, max, sports and watch appear in the top words.