word2vecで遊んでみた

この記事は、[ラクス Advent Calendar 2024](https://qiita.com/advent-calendar/2024/rakus) 19日目の記事です。

唐突にword2vecを触りたくなったので、遊んでみました。
word2vecは、単語をベクトル化し、その意味的な関係を数学的に表現できるものです。今回は、Wikipediaの日本語の記事から単語を抽出し、それらを用いてword2vecのモデルを作成しました。このモデルを用いて、単語同士の加算や減算、さらには2つの単語の類似度を計算して遊んでみました。

## 環境とライブラリ
### 環境
* Google Colaboratoryの無料版
   * CPU
      * RAM : 約12GB

### ライブラリ
* gensim :
    * word2vecのモデルを学習させるためのライブラリ

* wikiextractor :
    * WikipediaのXMLダンプファイルから記事のテキストデータを抽出するために使用
    * --jsonオプションを指定し、抽出したデータをJSON形式で保存

* MeCab :
    * 日本語のテキストを形態素解析し、分かち書き（単語分割）を行う
    * -Owakatiオプションで、単語をスペースで区切って出力

* json :
    * Wikipediaの抽出データはJSON形式で保存されているので、json.loads(line)を使ってデータを読み込む

* os :
    * ファイルを読み込むために使用

## 流れ
学習までの流れは以下の通りです。

1. Wikipediaの日本語記事をダウンロード
2. 記事のファイルから、単語に分割
3. word2vecに学習させ、モデルの作成

では、詳細に説明していきます。

### 1. Wikipediaの日本語記事をダウンロード
`wget`コマンドを用いて[Wikipediaの日本語記事の最新のデータダンプファイル](https://dumps.wikimedia.org/jawiki/latest/)をダウンロードします。データ量が多いので、私の環境では15分程度かかりました。

```bash
# Wikipediaの最新の日本語記事をダウンロード
!wget https://dumps.wikimedia.org/jawiki/latest/jawiki-latest-pages-articles.xml.bz2
# ダウンロードしたファイルの解凍
!bzip2 -d jawiki-latest-pages-articles.xml.bz2
```

### 2. 記事のファイルから、単語に分割
#### 記事をデータ変換
Wikipediaの日本語記事を解凍しただけでは、データとして扱いづらいので、`WikiExtractor`を用いて扱いやすい形式に変換します。

> WikiExtractorの余談
WikiExtractorはWikipediaのデータを抽出するためのツールです。主に以下のことができます。
・不要な情報（テンプレート、リンク、カテゴリ、メタデータなど）を除去
・記事のタイトルと本文だけを残す
・機械学習に利用可能な形式で保存

```bash
# wikiextractorをインストール
!pip install wikiextractor
# データ変換
!python -m wikiextractor.WikiExtractor jawiki-latest-pages-articles.xml --json --output extracted --processes 4
```

* `--json`
    * 抽出した記事をJSON形式で出力するオプション
* `--output extracted`
    * 抽出されたデータの保存先ディレクトリを指定
    * この場合は、extractedディレクトリ内
* `--processes 4`
    * 並列処理のために使用するプロセス数を指定

約230万記事を変換したため、だいだい１時間15分ほどかかりました。

#### 単語に分割
変換した記事を単語に分割します。日本語の文を単語に分割するために、形態素解析エンジンであるMeCabを用いました。

```bash
# pythonでMeCabを使うためにインストール
!pip install mecab-python3
# apt-getの更新
!sudo apt-get update
# MeCab本体と関連ライブラリ・辞書をインストール
!sudo apt-get install mecab libmecab-dev mecab-ipadic-utf8
```

次にpythonでWikipediaの記事データを単語に分割します。全記事に対して処理を行うと時間がかかってしまうので、ファイル数を指定できるようにしています。ただし、単語に分割する対象のファイルをランダムにすることで、偏りをなくすようにしています。
```python
import os
import json
import MeCab
import random
from typing import List

def read_wikipedia_data(directory: str, max_files: int = 10) -> List[List[str]]:
    """
    Wikipediaデータの一部をランダムに選び、形態素解析を行い、分かち書きされた文のリストを返す

    Args:
        directory (str): Wikipediaデータが格納されているディレクトリ
        max_files (int): 読み込むファイルの最大数

    Returns:
        List[List[str]]: 形態素解析後のトークン化された文のリスト
    """
    sentences = []
    mecab = MeCab.Tagger("-Owakati -r /etc/mecabrc")  # 分かち書き用
    all_files = []

    # ディレクトリ内のすべてのファイルを収集
    for root, _, files in os.walk(directory):
        all_files.extend([os.path.join(root, file) for file in files])

    # ファイルをシャッフルしてランダムに選択
    random.shuffle(all_files)
    selected_files = all_files[:max_files]

    for file_path in selected_files:
        try:
            with open(file_path, 'r', encoding='utf-8') as f:
                for line in f:
                    line = line.strip()
                    if not line:
                        continue

                    try:
                        data = json.loads(line)
                        text = data.get("text", "")
                        if text:
                            tokens = mecab.parse(text).strip().split()
                            sentences.append(tokens)
                    except json.JSONDecodeError:
                        print(f"JSONデコードエラーが発生しました: {line[:100]}...")

            print(f"処理済ファイル: {file_path}")

        except (IOError, OSError) as e:
            print(f"ファイルの読み込み中にエラーが発生しました: {file_path}. エラー: {e}")

    return sentences

if __name__ == "__main__":
    # ディレクトリと最大ファイル数を指定して実行
    input_directory = "extracted"
    max_files_to_process = 1000

    # 形態素解析処理を実行
    sentences = read_wikipedia_data(input_directory, max_files=max_files_to_process)
```

上記のコードにより、`sentences`に単語のリストが入りました。

### 3. word2vecに学習させ、モデルの作成
単語のリストが入っている`sentences`を使って、1単語あたり100次元のモデルを作成します。
まずは、word2vecを使用するために、`gensim`というライブラリをインストールします。

```bash
!pip install gensim
```

次にword2vecを使ってモデルを作成します。

```python
from gensim.models import Word2Vec

# Word2Vecモデルの学習
model = Word2Vec(sentences, vector_size=100, window=5, min_count=5, workers=4)
```

## 遊んでみた
先ほど作成したモデルを使って単語で遊んでみます。

```python
# モデルの読み込み
model = Word2Vec.load("wikipedia_word2vec.model")
```

### 類似単語の検索
ある単語に対して、モデルが上位5つの近しい単語を出力してくれます。

#### 「日本」で検索
```python
similar_words = model.wv.most_similar("日本", topn=5)
print("日本に似ている単語:", similar_words)
```
出力結果
```bash
日本に似ている単語: [('韓国', 0.7203667759895325), ('台湾', 0.6540617942810059), ('中国', 0.6530770063400269), ('アメリカ', 0.6398941278457642), ('米国', 0.628798246383667)]
```

このモデルだと、日本に一番近い単語は、韓国となります。すべて国であるので、今の所、良いモデルという感じがします。

#### 「Python」で検索
```python
similar_words = model.wv.most_similar("Python", topn=5)
print("Pythonに似ている単語:", similar_words)
```
出力結果
```bash
pythonに似ている単語: [('Perl', 0.9196841716766357), ('Ruby', 0.9135315418243408), ('Java', 0.9072768092155457), ('JavaScript', 0.8966066241264343), ('Pascal', 0.8810014128684998)]
```

近い単語は全部プログラミング言語でした。少しモデルが信頼できるようになりました笑。ちなみに、最初を小文字にした「python」だと、モデルにない語彙のため、類似検索ができませんでした（Wikipediaには、Pythonしかない？？？）。

### 2つの単語の類似度
word2vecでは、2つの単語の類似度を出してくれます。この類似度は、コサイン類似度を使用しており、-1から1の値を取ります。-1は反対の意味を表していることになり、1は同じ意味を表していることになります。
コードでは、%表記に変更しています。

#### 「人生」と「お金」
```python
similarity = model.wv.similarity("人生", "お金")
# %に変換
percentage = (similarity + 1) / 2 * 100
print("人生とお金の類似度:", percentage, "%")
```
出力結果
```bash
人生とお金の類似度: 71.56976908445358 %
```
まあ近しいかなといった感じですかね。

#### 「iPhone」と「Android」
```python
similarity = model.wv.similarity("iPhone", "Android")
# %に変換
percentage = (similarity + 1) / 2 * 100
print("iPhoneとAndroidの類似度:", percentage, "%")
```
出力結果
```bash
iPhoneとAndroidの類似度: 93.1020736694336 %
```
同じスマートフォンという分類上、かなり近い結果が得られました。

#### 「微積分」と「バンジージャンプ」
```python
similarity = model.wv.similarity("微積分", "バンジージャンプ")
# %に変換
percentage = (similarity + 1) / 2 * 100
print("微積分とバンジージャンプの類似度:", percentage, "%")
```
出力結果
```bash
微積分とバンジージャンプの類似度: 55.581021308898926 %
```
これは50%に近いので、関連性がない傾向にあるとなりますね。

### 単語の足し算引き算
word2vecは単語をベクトル化するものなので、単語同士に対して足し算引き算を行うということは、ベクトル同士の足し算引き算をするということになり、その結果のベクトルから、近しい単語を出してくれます。よって、単語に対して足し算引き算をすることができます。

#### 王様 - 男 + 女
```python
result = model.wv.most_similar(positive=["王様", "女"], negative=["男"], topn=5)
print("result:", result)
```
出力結果
```bash
result: [('奥様', 0.6555412411689758), ('お姫様', 0.6504137516021729), ('ラプンツェル', 0.6465022563934326), ('人魚', 0.6454244256019592), ('お父さん', 0.6436691284179688)]
```
直観的には、王女とか女王様とかになりそうでしたが、そうはなりませんでしたね。ただ、出力結果にも、まあ、納得するかなっていう感じですね。

#### 寿司 - 日本 + アメリカ
```python
result = model.wv.most_similar(positive=["寿司", "アメリカ"], negative=["日本"], topn=5)
print("result:", result)
```
出力結果
```bash
result: [('パン', 0.7157420516014099), ('ピザ', 0.7029860019683838), ('飲み物', 0.680216908454895), ('ケーキ', 0.661565899848938), ('サンドイッチ', 0.6426727771759033)]
```
寿司から日本を引いて、国の代表料理とし、そこにアメリカを加えることで、アメリカの代表料理を期待しましたが、それっぽい結果になりましたね。

## まとめ
初めてword2vecで遊んでみましたが、2013年に発表された技術ということもあり、学習用データを整える所や学習もやりやすかったです。自分の期待通りの結果にならないものもあったため、モデルに再考の余地ありという感じでした。もう少し、多くのデータを学習させたかったですが、単語の分解や学習にかなりのリソースや時間が取られてしまうため、今回はこのモデルで断念しました。

## おまけ
gensimには既に学習済みのモデルがあるので、そちらも使ってみました。

### モデルの読み込み
```bash
# 日本語のWord2Vecモデルをダウンロード
!wget https://dl.fbaipublicfiles.com/fasttext/vectors-crawl/cc.ja.300.vec.gz
```
```python
from gensim.models import KeyedVectors

# ダウンロードした日本語Word2Vecモデルをロード
model_path = '/content/cc.ja.300.vec.gz'  # ダウンロードしたファイルパス
model = KeyedVectors.load_word2vec_format(model_path, binary=False)
```

### 類似単語の検索
#### 「日本」で検索
```python
similar_words = model.most_similar("日本", topn=5)
print("日本に似ている単語:", similar_words)
```
出力結果
```bash
日本に似ている単語: [('国内', 0.6836407780647278), ('米国', 0.6735642552375793), ('アメリカ', 0.671046257019043), ('欧米', 0.6430664658546448), ('韓国', 0.641771674156189)]
```

#### 「Python」で検索
```python
similar_words = model.most_similar("Python", topn=5)
print("Pythonに似ている単語:", similar_words)
```
出力結果
```bash
Pythonに似ている単語: [('python', 0.7935450077056885), ('Clojure', 0.7304115295410156), ('OCaml', 0.7204217314720154), ('Cython', 0.7158461809158325), ('Ruby', 0.7061755657196045)]
```

### 2つの単語の類似度
#### 「人生」と「お金」
```python
similarity = model.similarity("人生", "お金")
# %に変換
percentage = (similarity + 1) / 2 * 100
print("人生とお金の類似度:", percentage, "%")
```
出力結果
```bash
人生とお金の類似度: 70.61346918344498 %
```

#### 「iPhone」と「Android」
```python
similarity = model.similarity("iPhone", "Android")
# %に変換
percentage = (similarity + 1) / 2 * 100
print("iPhoneとAndroidの類似度:", percentage, "%")
```
出力結果
```bash
iPhoneとAndroidの類似度: 83.55622887611389 %
```

#### 「微積分」と「バンジージャンプ」
```python
similarity = model.similarity("微積分", "バンジージャンプ")
# %に変換
percentage = (similarity + 1) / 2 * 100
print("微積分とバンジージャンプの類似度:", percentage, "%")
```
出力結果
```bash
微積分とバンジージャンプの類似度: 54.54240292310715 %
```

### 単語の足し算引き算
#### 王様 - 男 + 女
```python
result = model.most_similar(positive=["王様", "女"], negative=["男"], topn=5)
print("result:", result)
```
出力結果
```bash
result: [('女王', 0.4983246922492981), ('ラジオキッズ', 0.4979418218135834), ('王さま', 0.49087509512901306), ('熟', 0.4839142858982086), ('王妃', 0.4745190143585205)]
```

#### 寿司 - 日本 + アメリカ
```python
result = model.most_similar(positive=["寿司", "アメリカ"], negative=["日本"], topn=5)
print("result:", result)
```
出力結果
```bash
result: [('すし', 0.6206580996513367), ('カリフォルニアロール', 0.599497377872467), ('カルフォルニアロール', 0.5752031207084656), ('鮨', 0.5554982423782349), ('寿し', 0.5498672723770142)]
```

### まとめ
自分で作成したモデルよりは納得感高いものが増えた気がしますが、それでも納得しないようなものもちらほらありますね。自然言語処理の難しさを感じます。