既存の Language Identification モデルの精度を比較してみる

# 1. はじめに
多言語のデータセットを扱うとき、英語以外のデータセットには大体英語が混ざっているじゃないですか。
その影響を排除したい、でも目で見て判断するのは現実的じゃない・・・。
そこで「この文章って何語なの？」を判断してくれる Language Identification のモデルですぐに使えるものを比較します。
また、文章で学習されているモデルではありますが、単語単位でも精度が出るのかも検証します。

# 2. バージョンの確認

```bash
# Python
python==3.10.5
# Language Identification
fasttext-langdetect==1.0.5
langdetect==1.0.9
langid==1.1.6
stanza==1.4.2
transformers==4.26.1
# 前処理
ginza==5.1.2
ja-ginza==5.1.2
underthesea==6.2.0
```

# 3. 使用するライブラリ

## 3.1 `langdetect`
https://pypi.org/project/langdetect/

このライブラリ自体は2021年に公開されていますが、2014年の Java 実装を Python に移植したもののようです。
今回使用する5言語のうち、エスペラント語を除く4言語に対応しています。

```python
import langdetect

def identify_by_langdetect(data: list[str]) -> list[str]:
    return [langdetect.detect(item) for item in data]
```

## 3.2 `langid`
https://github.com/saffsd/langid.py/tree/master/langid

2014年に公開されたライブラリです。
今回使用する5言語すべてに対応しています。

```python
import langid

def identify_by_langid(data: list[str]) -> list[str]:
    return [langid.classify(item)[0] for item in data]
```

## 3.3 `fasttext-langdetect`
https://pypi.org/project/fasttext-langdetect/

fastText の Language Identification 部分のラッパーです。
このライブラリは2023年に公開されていますが、 fastText 自体の公開は2016年です。
今回使用する5言語すべてに対応しています。

```python
import ftlangdetect

def identify_by_fasttext(data: list[str]) -> list[str]:
    return [ftlangdetect.detect(item, low_memory=False)['lang'] for item in data]
```

## 3.4 `stanza`
https://stanfordnlp.github.io/stanza/langid.html

この機能の実装がいつかは分かりませんが、 stanza 自体は2020年に公開されています。
今回使用する5言語のうち、エスペラント語を除く4言語に対応しています。

```python
from stanza.models.common.doc import Document
from stanza.pipeline.core import Pipeline

nlp_stanza = Pipeline(lang='multilingual', processors='langid')

def identify_by_stanza(data: list[str]) -> list[str]:
    docs = [Document([], text=item) for item in data]
    nlp_stanza(docs)
    return [doc.lang for doc in docs]
```

## 3.5 `xlm-roberta-base-language-detection`
https://huggingface.co/papluca/xlm-roberta-base-language-detection

HuggingFace の xlm-roberta-base を fine-tuning したものです。
2021年に公開されています。
今回使用する5言語のうち、エスペラント語を除く4言語に対応しています。

```python
from transformers import AutoTokenizer, AutoModelForSequenceClassification

tokenizer_papluca = AutoTokenizer.from_pretrained('papluca/xlm-roberta-base-language-detection')
model_papluca = AutoModelForSequenceClassification.from_pretrained('papluca/xlm-roberta-base-language-detection')

def identify_by_papluca(data: list[str]) -> list[str]:
    encoded_input = tokenizer_papluca(data, padding=True, truncation=True, max_length=128, return_tensors='pt')
    logits = model_papluca(**encoded_input).logits.detach().numpy()
    return [model_papluca.config.id2label[id_] for id_ in logits.argmax(1)]
```

## 3.6 `xlm-roberta-base-finetuned-language-detection-new`
https://huggingface.co/dinalzein/xlm-roberta-base-finetuned-language-identification

上とほぼ同じに見えますが、こちらは2022年に公開されたものです。
今回使用する5言語のうち、エスペラント語を除く4言語に対応しています。

```python
from transformers import AutoTokenizer, AutoModelForSequenceClassification

tokenizer_dinalzein = AutoTokenizer.from_pretrained('dinalzein/xlm-roberta-base-finetuned-language-identification')
model_dinalzein = AutoModelForSequenceClassification.from_pretrained('dinalzein/xlm-roberta-base-finetuned-language-identification')

def identify_by_dinalzein(data: list[str]) -> list[str]:
    encoded_input = tokenizer_dinalzein(data, padding=True, truncation=True, max_length=128, return_tensors='pt')
    logits = model_dinalzein(**encoded_input).logits.detach().numpy()
    return [model_dinalzein.config.id2label[id_] for id_ in logits.argmax(1)]
```

# 4. 検証方法
今回は、日本語、英語、ドイツ語、エスペラント語、ベトナム語の5言語について検証を行います。
データは [Tatoeba](https://tatoeba.org/ja/downloads) から取得して、文章・単語それぞれランダムに100個抽出し、正解率を求めます。これを100回繰り返して、その平均と標準偏差を計算します。

```python: データを取得するコード
import random
from collections.abc import Generator
from pathlib import Path
from typing import Literal, Optional

import underthesea
import spacy

nlp_ja = spacy.load('ja_ginza', enable=[''])

def tokenize(sent: str,
             lang: str
             ) -> list[str]:
    """
    言語に合わせて文章を単語に分ける。
    """
    match lang:
        case 'ja':
            return [token.text for token in nlp_ja(sent)]
        case 'vi':
            return underthesea.word_tokenize(sent)
        case _:
            return sent.split(' ')

def extract_words(sents: list[str],
                  lang: Literal['de', 'en', 'eo', 'ja', 'vi']
                  ) -> Generator[str, None, None]:
    """
    文章群から単語を抽出する。
    """
    for sent in sents:
        for word in tokenize(sent, lang):
            if not word.isalpha():
                continue
            yield word

def sample_sentences(data_path: Path,
                     num_sampling: Optional[int] = 100,
                     lower: bool = True,
                     ) -> list[str]:
    """
    指定した数の文章をサンプリングする。
    """
    lines = Path(data_path).read_text(encoding='utf-8').splitlines()
    sents = [
        sent.lower() if lower else sent
        for line in lines
        if (sent := line.split('\t')[2])
    ]
    random.shuffle(sents)

    return sents if num_sampling is None else sents[:num_sampling]

def sample_words(data_path: Path,
                 lang: Literal['de', 'en', 'eo', 'ja', 'vi'],
                 num_sampling: Optional[int] = 100,
                 lower: bool = True,
                 ) -> list[str]:
    """
    指定した数の単語をサンプリングする。
    """
    sents = sample_sentences(data_path, num_sampling=None, lower=lower)

    words = set()
    for word in extract_words(sents, lang):
        words.add(word)
        if len(words) == num_sampling:
            break

    return list(words)
```

# 5. 検証結果

## 5-1. 文章単位
まずは文章の言語を判別してみた結果です。

| (文章単位) | de | en | eo | ja | vi |
| :-: | :-: | :-: | :-: | :-: | :-: |
| langdetect | 0.9131 (0.0270) | 0.9046 (0.0257) | - | 0.9989 (0.0031) | 0.9992 (0.0027) |
| langid | 0.9713 (0.0171) | 0.9677 (0.0168) | 0.8525 (0.0351) | 0.9990 (0.0030) | 0.9980 (0.0047) |
| fasttext | 0.9940 (0.0083) | 0.9974 (0.0046) | 0.9970 (0.0056) | 0.9995 (0.0022) | 0.9965 (0.0061) |
| stanza | 0.9545 (0.0216) | 0.9925 (0.0083) |-| 0.9993 (0.0026) | 0.9996 (0.0020) |
| papluca | 0.9908 (0.0099) | 0.9858 (0.0116) | - | 0.9991 (0.0032) | 0.9974 (0.0046) |
| dinalzein | 0.9868 (0.0116) | 0.9623 (0.0206) | - | 0.9993 (0.0026) | 0.9995 (0.0022) |

全体的に精度は高く、特に`fastText`はベトナム語以外で一位になっています。
エラーを確認すると、
- ドイツ語は英語と誤判定されることが多いが、その他ヨーロッパ言語の場合もある。
- 英語はスペイン語やイタリア語と誤判定されることが多い。
- エスペラント語もスペイン語やイタリア語と誤判定されることが多い。
- 日本語は中国語だと誤判定されることが多い。
- ベトナム語は色々な言語に誤判定される。

## 5-2. 単語単位
次に単語の言語を判別してみた結果です。
ちなみに、日本語を`langdetect`で判別しようとしたところ一部（15/100）エラーが出たため、その分は除外して計算しています。

| (単語単位) | de | en | eo | ja | vi |
| :-: | :-: | :-: | :-: | :-: | :-: |
| langdetect | 0.4063 (0.0417) | 0.2568 (0.0372) | - | 0.6788 (0.0404) | 0.8645 (0.0276) |
| langid | 0.5059 (0.0445) | 0.9278 (0.0265) | 0.0622 (0.0221) | 0.6901 (0.0395) | 0.6834 (0.0362) |
| fasttext | 0.7829 (0.0381) | 0.8932 (0.0282) | 0.6655 (0.0377) | 0.8634 (0.0344) | 0.7954 (0.0300) |
| stanza | 0.5916 (0.0441) | 0.4879 (0.0417) | - | 0.8447 (0.0342) | 0.7791 (0.0337) | 
| papluca | 0.6622 (0.0400) | 0.7556 (0.0331) | - | 0.3976 (0.0454) | 0.4152 (0.0368) |
| dinalzein | 0.7122 (0.0351) | 0.4215 (0.0439) | - | 0.7459 (0.0416) | 0.8716 (0.0280) |

やはり文章単位と比べるとかなり精度が下がりました。
こちらもベトナム語以外では`fastText`の精度が最も高く、ベトナム語は`dinalzein`さんのモデルが一位でした。
エラーを確認すると、
- ドイツ語、英語、エスペラント語、ベトナム語は色々な言語に誤判定される。
- 日本語は中国語の誤判定が多いが、それ以外の言語にも誤判定される。

# 6. 終わりに
`XLM-RoBERTa`なども検証しましたが、全体として`fastText`が最も精度が高い、という結果になりました。
今回は最も確率の高い出力のみを使って正解率を出しましたが、例えばエスペラント語の単語はもともとロマンス系の言語に似ているので、もう少し幅を持って（出力の上位〇位以内とか上位〇%とか）検証すると、また新しい発見があるかもしれないです。