自然言語処理編 - LSTM モデル学習 その壱
LSTM(Long Short-Term Memory) モデルの学習を、試してみる。 LSTM モデルは、RNN(Recurrent Neural Network)の一種である。 これらは、時系列データをネットワークで解析するモデルとなる。 LSTM は、RNN と比べて、より離れた距離の依存関係を捉えることができるようだ。
ここでは、単語の品詞タグ付けを行うためのモデルを学習してみる。 実装は、PyTorch を利用し、 学習に利用するデータは、Wikipedia のデータを加工して利用する。
学習に利用するデータを準備する
学習用のデータには、Wikipedia のデータを利用する。 利用する Wikipedia のデータは、マルチストリーム形式とする。 マルチストリーム形式のファイルは、データファイルとインデックスファイルで構成されている。 データファイルは、100 項目毎にデータを圧縮し格納している。 インデックスファイルは、データファイルのオフセット位置と、 項目 ID および、タイトルが記載されている。 データは、インデックスファイル内のオフセットを参照して、 データファイルから部分的に取り出すことができる。 ただし、取り出すための処理を記述する必要がある。 データ取り出し処理は、 Qiita - Wikipediaのダンプからページを取り出す を参考にさせていただいた。
必要なパッケージをインストールする
以下のパッケージをインストールする。 wikitextparser は、Wikipedia のページデータを解析するために利用する。
pip install wikitextparser
Wikipedia のデータファイルをダウンロードする
学習用のデータは、
Wikipedia:データベースダウンロード
ページのライセンスを確認し、問題が無ければ、
ページの入手方法にある、dumps.wikimedia.org/jawiki/
リンクをクリックして、
表示されたページの 20250120
をクリックし、以下のファイルをダウンロードして、
~/models/ ディレクトリに格納する。
- インデックスファイル (28.3 MB)
jawiki-20250120-pages-articles-multistream-index.txt.bz2
- データファイル (4.1 GB)
jawiki-20250120-pages-articles-multistream.xml.bz2
インデックスファイルを変換する
インデックスファイルは、以下の形式のテキストファイルを圧縮したものとなる。
- UTF-8
- LF 改行
- ヘッダ無し
- コロン(:) 区切り
- カラムは、offset, id, title
title には、コロン文字が含まれるが、ダブルクォートで囲まれていないため、
事前に Pandas で利用しやすいように変換しておく。
変換後のファイルは、jawiki-20250120-pages-articles-multistream-index-fix.txt.bz2
とする。
convert_index_file.py
import bz2
def main():
"""Wikipedia マルチストリームデータのインデックスファイルを Pandas で読み込みやすいように変換する。
インデックスファイルは、コロン区切りデータなので、タイトルにコロンが含まれている場合にダブルクォートで囲むようにする。
"""
with bz2.open(
# Wikipedia マルチストリームデータのインデックスファイル
'/home/ubuntu/models/jawiki-20250120-pages-articles-multistream-index.txt.bz2',
'rt',
encoding='utf-8',
) as rf, bz2.open(
# 出力ファイル
'/home/ubuntu/models/jawiki-20250120-pages-articles-multistream-index-fix.txt.bz2',
'wt',
encoding='utf-8',
) as wf:
for line in rf:
if line.count(':') >= 3:
# title にコロンが含まれている場合
words = line.rstrip('\n').split(':')
# 3 カラム目以降をダブルクォートで囲み、エスケープ処理をする
title = '"' + ':'.join(words[2:]).replace('"', '""') + '"'
line = ':'.join([*words[:2], title]) + '\n'
wf.write(line)
if __name__ == '__main__':
main()
最初は、インデックスファイルを、Pandas で直接読み込もうとしたが、断念した。 title カラムの読み込みでエラーとなるため、 on_bad_lines パラメータに処理関数を渡して対応しようとしたが、 offset が index として扱われ、title にコロンを含む行では、 id に、title の一部(コロンまでの値)が設定されてしまう。 対処として index_col=False パラメータを指定して、 index を連番で付与するようにしたが、 今度は、on_bad_lines の指定が無視されるという仕様(バグ?)で、 title のコロン以降が欠落する状態となった。
Wikipedia データファイルから、データを抽出する
学習用のデータを 200 項目および、テスト用データ 20 項目を抽出する。 記号などの不要な文字列を除外して、 圧縮ファイル(jawiki-data.txt.bz2, jawiki-test.txt.bz2)に出力する。
extract_wikipedia_data.py
import bz2
import io
import re
from collections import OrderedDict
from xml.etree import ElementTree
import numpy as np
import pandas as pd
import wikitextparser as wtp
# 学習データ用に取得する項目数
TRAIN_DATA_ITEM_SIZE = 200
# テストデータ用に取得する項目数
TEST_DATA_ITEM_SIZE = 20
# ブロックデータキャッシュ上限数(LRU アルゴリズム)
BLOCK_DATA_CACHE_SIZE = 200
# ブロックサイズキャッシュ用
block_size_cache = {}
# ブロックデータキャッシュ用
block_data_cache = OrderedDict()
def get_block_size(offset: int, offsets: int) -> int:
"""対象のオフセットから始まるブロックのサイズを取得する。
Args:
offset (int): 対象のオフセット値.
offsets (int): 全オフセット値の配列.
Returns:
int: 対象オフセットから始まるブロックのサイズを返す。次のオフセットが無い場合、-1 を返す。
"""
if offset in block_size_cache:
# キャッシュに存在する場合
return block_size_cache[offset]
else:
# -1 は、データを末尾まで詠み込む
result = -1
if np.max(offsets) > offset:
# ブロックの最後が取得できる場合
result = np.min(offsets[offsets > offset]) - offset
# キャッシュに保存する
block_size_cache[offset] = result
return result
def read_block_data(file: io.FileIO, offset: int, read_size: int) -> ElementTree:
"""ブロックデータを詠み込む。
Args:
file (io.FileIO): ファイルオブジェクト
offset (int): 読み込み開始位置
read_size (int): ブロックのサイズ
Returns:
ElementTree: ブロックの XML データを返す。
"""
if offset in block_data_cache:
# キャッシュに存在する場合
return block_data_cache[offset]
else:
# 圧縮ファイルのオフセット位置に移動する
file.seek(offset)
# 指定したサイズのブロックを読み込む
block = file.read(read_size)
# 圧縮されたブロックデータを解凍する
data = bz2.decompress(block)
# 解凍したバイトデータを文字列に変換する
xml = data.decode(encoding='utf-8')
# XML データを ElementTree オブジェクトに変換する(root 要素が必要なので追加する)
result = ElementTree.fromstring("<root>" + xml + "</root>")
# キャッシュに保存する
block_data_cache[offset] = result
if len(block_data_cache) > BLOCK_DATA_CACHE_SIZE:
# キャッシュ上限を超えた場合、古いデータから削除する
block_data_cache.popitem(last=False)
return result
def normalize_text(text: str) -> str:
"""正規化した文字列を取得する。
Args:
text (str): 文字列
Returns:
str: 正規化後の文字列
"""
# 英数字・記号類を削除する
text = ''.join(ch for ch in text if not (
'!' <= ch <= '~'
or ch in '=+:()「」【】『』…�→'
))
# 空白・タブ文字を一つの空白に置換する
text = re.sub(r'[\t ]+', ' ', text)
# 行頭・行末の空白を削除する
text = text.strip(' ')
if len(text) <= 10:
# 10 文字以下の行は除外する
return ''
return text
def scrape_page(block: ElementTree, page_id: int) -> list[str]:
"""
ページデータから、テキストデータを抽出したテキストリストを取得する。
Args:
block (ElementTree): データブロック
page_id (int): ページ ID
Returns:
list[str]: テキストリスト
"""
lines = []
# XML ブロックデータからページデータを取得する
page = block.find(f'page/[id="{page_id}"]')
# ページのタイトルを取得する
title = page.find('title').text
# ページのテキスト(Wikipedia テンプレート)を取得する
text = page.find('revision/text').text
# Wikipedia テンプレートを解析する
parsed = wtp.parse(text)
# タイトルを一行目に追加する
lines.append(f'{title}\n')
for section in parsed.sections:
# セクション文字列を一行ずつ(改行コード付き)処理する
for line in section.plain_text().splitlines(keepends=True):
line = normalize_text(line)
if line.strip():
lines.append(line)
if not lines[-1].endswith('\n'):
lines.append('\n')
return lines
def main():
"""Wikipedia のデータを先頭から順に取得し、行ごとに、不要な文字列を除外した上で、圧縮形式で出力ファイルに書き込む。"""
# インデックスデータを CSV の圧縮ファイルから詠み込む
index = pd.read_csv(
'/home/ubuntu/models/jawiki-20250120-pages-articles-multistream-index-fix.txt.bz2',
sep=':',
names=['offset', 'id', 'title'],
dtype={'offset': int, 'id': int, 'title': str},
# 欠損値置換処理を無効化(文字列の "NaN" が、欠損値と判定されてしまうため)
na_filter=False
)
# オフセットを重複無しで取り出す
offsets = index['offset'].unique()
# オフセットを昇順にソートする
np.sort(offsets)
indexes = index.query(
'~(title.str.contains("Wikipedia:削除") or title.str.contains("Wikipedia:投稿ブロック依頼"))'
).head(TRAIN_DATA_ITEM_SIZE + TEST_DATA_ITEM_SIZE)
# 書き込み行数
total_lines = 0
with (
# Wikipedia データをバイナリモードで開く
open('/home/ubuntu/models/jawiki-20250120-pages-articles-multistream.xml.bz2', 'rb') as f,
# 出力ファイルを開く
bz2.open('jawiki-data.txt.bz2', 'wt') as wf_train,
bz2.open('jawiki-test.txt.bz2', 'wt') as wf_test
):
for row in indexes.itertuples():
# ブロックサイズを取得する
read_size = get_block_size(row.offset, offsets)
# ブロックデータを読み込む
block = read_block_data(f, row.offset, read_size)
# ページデータからテキストデータを抽出する
lines = scrape_page(block, row.id)
# テキストデータをファイルに書き込む
if row.Index < TRAIN_DATA_ITEM_SIZE:
# 学習用データを書き出す
wf_train.writelines(lines)
else:
# テスト用データを書き出す
wf_test.writelines(lines)
total_lines += len(lines)
print(f'学習・テストデータ行数: {total_lines}')
if __name__ == '__main__':
main()
学習用データを作成する
学習用データは、訓練データと、教師データとする。 文を形態素解析し、単語ごとに、品詞を取得する。 単語と、品詞は、ID に変換して、学習用データに利用する。 各 ID は、ファイルに保存して、再変換しても ID が変わらないようにしている。 実行後は、以下のファイルが作成される。
- lstm-pos.txt.bz2:品詞 ID 辞書
- lstm-word.txt.bz2:単語 ID 辞書
- lstm-simple-x-train.npy:学習用訓練データ
- lstm-simple-y-train.npy:学習用教師データ
- lstm-simple-x-test.npy:テスト用訓練データ
- lstm-simple-y-test.npy:テスト用教師データ
generate_train_data_simple.py
import bz2
import os
from collections import defaultdict
import MeCab
import numpy as np
# 学習用文章データの入力ファイル名
TRAIN_FILE = 'jawiki-data.txt.bz2'
# テスト用文章データの入力ファイル名
TEST_FILE = 'jawiki-test.txt.bz2'
# 処理済みの単語を重複なく保持する入出力ファイル名
WORD_FILE = 'lstm-word.txt.bz2'
# 処理済みの品詞を重複なく保持する入出力ファイル名
POS_FILE = 'lstm-pos.txt.bz2'
# 学習用訓練データ出力ファイル名
X_TRAIN_FILE = 'lstm-simple-x-train.npy'
# 学習用教師データ出力ファイル名
Y_TRAIN_FILE = 'lstm-simple-y-train.npy'
# テスト用訓練データ出力ファイル名
X_TEST_FILE = 'lstm-simple-x-test.npy'
# テスト用教師データ出力ファイル名
Y_TEST_FILE = 'lstm-simple-y-test.npy'
def load_ids(file_path: str) -> dict[str, int]:
"""ID 辞書を読み込む。
Args:
file_path (str): ファイルパス
Returns:
dict[str, int]: ID 辞書
"""
# インデックス 0 は、データのパディング用に利用するため、空文字で初期化しておく。
ids = {'': 0}
if os.path.isfile(file_path):
with bz2.open(file_path, 'rt', encoding='utf-8') as f:
for line in f:
key, index = line.rstrip('\n').split('\t')
ids[key] = int(index)
return ids
def store_ids(file_path: str, ids: dict[str, int]) -> None:
"""ID 辞書を保存する。
Args:
file_path (str): ファイルパス
ids (dict[str, int]): ID 辞書
"""
with bz2.open(file_path, 'wt') as f:
f.writelines(f'{key}\t{index}\n' for key, index in ids.items())
def create_id_dict(file_path: str) -> dict[str, int]:
"""ファイルから ID 辞書を作成する。
Args:
file_path (str): ファイルパス
Returns:
ID 辞書
"""
id_dict = defaultdict(lambda: len(id_dict))
# データパディング用
id_dict[''] = 0
if os.path.exists(file_path):
id_dict.update(load_ids(file_path))
return id_dict
def convert(
input_file: str,
x_file: str,
y_file: str,
word_ids: dict[str, int],
pos_ids: dict[str, int]
):
"""文章データを形態素解析し、ID に変換して保存する。
Args:
input_file (str): 入力ファイルパス
x_file (str): 訓練データ出力ファイルパス
y_file (str): 教師データ出力ファイルパス
word_ids (dict[str, int]): 単語 ID 辞書
pos_ids (dict[str, int]): 品詞 ID 辞書
"""
with bz2.open(input_file, 'rt', encoding='utf-8') as rf:
tagger = MeCab.Tagger()
# 訓練データ(単語 ID)
x_train = []
# 教師データ(品詞 ID)
y_train = []
for line in rf:
# 単語 ID 配列
words = []
# 品詞 ID 配列
poses = []
# 文章を形態素解析する
node = tagger.parseToNode(line)
while node:
word = node.surface
pos = node.feature.split(',')[0]
words.append(word_ids[word])
poses.append(pos_ids[pos])
# 次の単語にする
node = node.next
# 訓練データ、教師データを配列に追加する
x_train.append(words)
y_train.append(poses)
# 訓練データをファイルに保存する
x_train = np.array(x_train, dtype=object, copy=False)
np.save(x_file, x_train)
# 教師データをファイルに保存する
y_train = np.array(y_train, dtype=object, copy=False)
np.save(y_file, y_train)
def main():
"""
文章データを形態素解析し、単語と品詞を ID に置換して、学習用データ(訓練データ、教師データ)を作成する。
"""
# ID 辞書を読み込む
word_ids = create_id_dict(WORD_FILE)
pos_ids = create_id_dict(POS_FILE)
# 学習用データを変換する
print('学習用データ作成中...')
convert(TRAIN_FILE, X_TRAIN_FILE, Y_TRAIN_FILE, word_ids, pos_ids)
# テスト用データを変換する
print('テスト用データ作成中...')
convert(TEST_FILE, X_TEST_FILE, Y_TEST_FILE, word_ids, pos_ids)
# ID 辞書を保存する
store_ids(WORD_FILE, word_ids)
store_ids(POS_FILE, pos_ids)
print(f'単語 ID 数: {len(word_ids)}')
print(f'品詞 ID 数: {len(pos_ids)}')
if __name__ == '__main__':
main()
まとめ
モデル学習用のデータの準備を行ったが、記事がかなりの分量となってしまった。 この記事では、学習用データに記号や数字などを含めないようにしている。 モデルの利用目的によっては、除外するものを検討する必要がありそうだ。 次の記事では、モデルの学習と推論を試す。
- 前の記事:自然言語処理編 - 文章のベクトル化
- 次の記事:自然言語処理編 - LSTM モデル学習 その弐