9.2. 生テキストを系列データに変換する

この本全体を通して、 私たちはしばしば、 単語、文字、または単語片の系列として表現された テキストデータを扱う。 まずは、生テキストを 適切な形式の系列へ変換するための 基本的な道具が必要である。 典型的な前処理パイプラインは 次の手順を実行する。

  1. テキストを文字列としてメモリに読み込む。

  2. 文字列をトークン(例:単語や文字)に分割する。

  3. 各語彙要素を数値インデックスに対応付けるための語彙辞書を構築する。

  4. テキストを数値インデックスの系列に変換する。

%load_ext d2lbook.tab
tab.interact_select(['mxnet', 'pytorch', 'tensorflow', 'jax'])
import collections
import re
from d2l import torch as d2l
import torch
import random
import collections
import re
from d2l import mxnet as d2l
from mxnet import np, npx
import random
npx.set_np()
import collections
from d2l import jax as d2l
import jax
from jax import numpy as jnp
import random
import re
import collections
import re
from d2l import tensorflow as d2l
import tensorflow as tf
import random

9.2.1. データセットの読み込み

ここでは、H. G. Wells の 『タイムマシン』を扱う。 この本は 3 万語強から成る。 実際のアプリケーションでは通常、 はるかに大規模なデータセットを扱うが、 前処理パイプラインを示すには これで十分である。 以下の _download メソッドは 生テキストを文字列として読み込む。

class TimeMachine(d2l.DataModule): #@save
    """The Time Machine dataset."""
    def _download(self):
        fname = d2l.download(d2l.DATA_URL + 'timemachine.txt', self.root,
                             '090b5e7e70c295757f55df93cb0a180b9691891a')
        with open(fname) as f:
            return f.read()

data = TimeMachine()
raw_text = data._download()
raw_text[:60]
Downloading ../data/timemachine.txt from http://d2l-data.s3-accelerate.amazonaws.com/timemachine.txt...
'The Time Machine, by H. G. Wells [1898]nnnnnInnnThe Time Tra'

簡単のため、生テキストの前処理では句読点と大文字小文字を無視する。

@d2l.add_to_class(TimeMachine)  #@save
def _preprocess(self, text):
    return re.sub('[^A-Za-z]+', ' ', text).lower()

text = data._preprocess(raw_text)
text[:60]
'the time machine by h g wells i the time traveller for so it'

9.2.2. トークン化

トークンとは、テキストの原子的(不可分な)単位である。 各タイムステップは 1 つのトークンに対応するが、 何を正確にトークンとみなすかは設計上の選択である。 たとえば、文 “Baby needs a new pair of shoes” を 7 語の系列として表現することもできる。 この場合、すべての単語の集合は 大きな語彙(通常は数万語から数十万語)を構成する。 あるいは、同じ文を 30 文字からなる、より長い系列として表現することもでき、 その場合ははるかに小さな語彙を使う (ASCII 文字は 256 種類しかない)。 以下では、前処理済みテキストを 文字の系列にトークン化する。

@d2l.add_to_class(TimeMachine)  #@save
def _tokenize(self, text):
    return list(text)

tokens = data._tokenize(text)
','.join(tokens[:30])
't,h,e, ,t,i,m,e, ,m,a,c,h,i,n,e, ,b,y, ,h, ,g, ,w,e,l,l,s, '

9.2.3. 語彙

これらのトークンはまだ文字列である。 しかし、モデルへの入力は最終的には 数値入力でなければならない。 次に、語彙を構築するためのクラスを導入する。 すなわち、各異なるトークン値を 一意のインデックスに対応付けるオブジェクトである。 まず、訓練 コーパス に含まれる 一意なトークンの集合を求める。 次に、各一意トークンに数値インデックスを割り当てる。 まれな語彙要素は、便宜上しばしば除外される。 訓練時またはテスト時に、 以前に見たことがないトークンや 語彙から除外されたトークンに遭遇した場合は、 特別な “<unk>” トークンで表し、 これが 未知 の値であることを示す。

class Vocab:  #@save
    """Vocabulary for text."""
    def __init__(self, tokens=[], min_freq=0, reserved_tokens=[]):
        # Flatten a 2D list if needed
        if tokens and isinstance(tokens[0], list):
            tokens = [token for line in tokens for token in line]
        # Count token frequencies
        counter = collections.Counter(tokens)
        self.token_freqs = sorted(counter.items(), key=lambda x: x[1],
                                  reverse=True)
        # The list of unique tokens
        self.idx_to_token = list(sorted(set(['<unk>'] + reserved_tokens + [
            token for token, freq in self.token_freqs if freq >= min_freq])))
        self.token_to_idx = {token: idx
                             for idx, token in enumerate(self.idx_to_token)}

    def __len__(self):
        return len(self.idx_to_token)

    def __getitem__(self, tokens):
        if not isinstance(tokens, (list, tuple)):
            return self.token_to_idx.get(tokens, self.unk)
        return [self.__getitem__(token) for token in tokens]

    def to_tokens(self, indices):
        if hasattr(indices, '__len__') and len(indices) > 1:
            return [self.idx_to_token[int(index)] for index in indices]
        return self.idx_to_token[indices]

    @property
    def unk(self):  # Index for the unknown token
        return self.token_to_idx['<unk>']

ここで、データセットのための 語彙を構築 し、 文字列の系列を数値インデックスのリストに変換する。 情報は失われておらず、 データセットを元の(文字列)表現に 簡単に戻せることに注意せよ。

vocab = Vocab(tokens)
indices = vocab[tokens[:10]]
print('indices:', indices)
print('words:', vocab.to_tokens(indices))
indices: [21, 9, 6, 0, 21, 10, 14, 6, 0, 14]
words: ['t', 'h', 'e', ' ', 't', 'i', 'm', 'e', ' ', 'm']

9.2.4. まとめて扱う

上のクラスとメソッドを用いて、 以下の TimeMachine クラスの build メソッドに すべてを まとめる。 このメソッドは、トークンインデックスのリストである corpus と、 『タイムマシン』 コーパスの語彙である vocab を返す。 ここで行った変更は次のとおりである。 (i) 後の節での学習を簡単にするため、 テキストを単語ではなく文字にトークン化する。 (ii) corpus はトークンリストのリストではなく単一のリストである。 これは、『タイムマシン』 データセットの各テキスト行が 必ずしも文や段落ではないためである。

@d2l.add_to_class(TimeMachine)  #@save
def build(self, raw_text, vocab=None):
    tokens = self._tokenize(self._preprocess(raw_text))
    if vocab is None: vocab = Vocab(tokens)
    corpus = [vocab[token] for token in tokens]
    return corpus, vocab

corpus, vocab = data.build(raw_text)
len(corpus), len(vocab)
(173428, 28)

9.2.5. 探索的な言語統計

実際のコーパスと、単語に対して定義した Vocab クラスを用いると、 コーパス内での単語の使われ方に関する基本統計を調べられる。 以下では、『タイムマシン』 で使われている単語から語彙を構築し、 最も頻出する 10 語を表示する。

words = text.split()
vocab = Vocab(words)
vocab.token_freqs[:10]
[('the', 2261),
 ('i', 1267),
 ('and', 1245),
 ('of', 1155),
 ('a', 816),
 ('to', 695),
 ('was', 552),
 ('in', 541),
 ('that', 443),
 ('my', 440)]

最も頻出する 10 語は、 あまり説明的ではないことに注意せよ。 任意の本を選んでも、 非常によく似たリストが得られるのではないかと 想像できるかもしれない。 “the” や “a” のような冠詞、 “i” や “my” のような代名詞、 “of”、“to”、“in” のような前置詞は、 一般的な統語的役割を果たすため頻繁に現れる。 このように、一般的だが特に説明的ではない単語は しばしばストップワードと呼ばれ、 いわゆる bag-of-words 表現に基づく 従来世代のテキスト分類器では、 たいてい除外されていた。 しかし、それらは意味を持っており、 現代の RNN や Transformer ベースの ニューラルモデルを扱う際に 必ずしも除外する必要はない。 さらにリストの下の方を見ると、 単語頻度が急速に減衰していることがわかる。 \(10^{\textrm{th}}\) に頻出する単語は、 最頻出単語の 1/5 未満しか現れない。 単語頻度は、順位が下がるにつれて べき乗則分布(具体的には Zipf 分布)に従う傾向がある。 よりよく理解するために、単語頻度の図を描く。

freqs = [freq for token, freq in vocab.token_freqs]
d2l.plot(freqs, xlabel='token: x', ylabel='frequency: n(x)',
         xscale='log', yscale='log')
../_images/output_text-sequence_8f61f3_31_0.svg

最初の数語を例外として扱えば、 残りのすべての単語は、対数–対数プロット上で おおむね直線に従う。 この現象は Zipf の法則 によって捉えられる。 これは、\(i^\textrm{th}\) に頻出する単語の頻度 \(n_i\) が次を満たすことを述べている。

(9.2.1)\[n_i \propto \frac{1}{i^\alpha},\]

これは次と同値である。

(9.2.2)\[\log n_i = -\alpha \log i + c,\]

ここで \(\alpha\) は分布を特徴づける指数であり、 \(c\) は定数である。 単語を出現回数の統計でモデル化しようとするなら、 ここで立ち止まって考えるべきだろう。 結局のところ、頻度の低い単語としても知られる テール部分の頻度を大きく過大評価してしまうからである。 では、2 語連続の組(bigram)、3 語連続の組(trigram)など、他の単語の組み合わせはどうだろうか。 さらに先まで見てみよう。 bigram の頻度が単一語(unigram)の頻度と同じように振る舞うかを確認する。

bigram_tokens = ['--'.join(pair) for pair in zip(words[:-1], words[1:])]
bigram_vocab = Vocab(bigram_tokens)
bigram_vocab.token_freqs[:10]
[('of--the', 309),
 ('in--the', 169),
 ('i--had', 130),
 ('i--was', 112),
 ('and--the', 109),
 ('the--time', 102),
 ('it--was', 99),
 ('to--the', 85),
 ('as--i', 78),
 ('of--a', 73)]

ここで注目すべき点が 1 つある。 最頻出の 10 個の語の組のうち 9 個はストップワード同士から成り、 実際の本に関係するものは “the time” だけである。 さらに、trigram の頻度も同じように振る舞うかを見てみよう。

trigram_tokens = ['--'.join(triple) for triple in zip(
    words[:-2], words[1:-1], words[2:])]
trigram_vocab = Vocab(trigram_tokens)
trigram_vocab.token_freqs[:10]
[('the--time--traveller', 59),
 ('the--time--machine', 30),
 ('the--medical--man', 24),
 ('it--seemed--to', 16),
 ('it--was--a', 15),
 ('here--and--there', 15),
 ('seemed--to--me', 14),
 ('i--did--not', 14),
 ('i--saw--the', 13),
 ('i--began--to', 13)]

では、これら 3 つのモデル、すなわち unigram、bigram、trigram における トークン頻度を可視化してみよう。

bigram_freqs = [freq for token, freq in bigram_vocab.token_freqs]
trigram_freqs = [freq for token, freq in trigram_vocab.token_freqs]
d2l.plot([freqs, bigram_freqs, trigram_freqs], xlabel='token: x',
         ylabel='frequency: n(x)', xscale='log', yscale='log',
         legend=['unigram', 'bigram', 'trigram'])
../_images/output_text-sequence_8f61f3_37_0.svg

この図は非常に興味深いものである。 第一に、unigram の単語だけでなく、単語列も Zipf の法則に従っているように見えるが、 系列長に応じて (9.2.1) の指数 \(\alpha\) は小さくなる。 第二に、異なる \(n\)-gram の数はそれほど多くない。 これは、言語にはかなり多くの構造があることを示唆している。 第三に、多くの \(n\)-gram は非常にまれにしか現れない。 このことは、ある種の手法が言語モデリングに適さないことを意味し、 深層学習モデルの利用を動機づける。 これについては次節で議論する。

9.2.6. まとめ

テキストは、深層学習で遭遇する最も一般的な系列データ形式の 1 つである。 トークンとして何を採用するかの一般的な選択肢は、文字、単語、単語片である。 テキストを前処理するには、通常、(i) テキストをトークンに分割し、(ii) トークン文字列を数値インデックスに写像する語彙を構築し、(iii) モデルが扱えるようにテキストデータをトークンインデックスに変換する。 実際には、単語頻度は Zipf の法則に従う傾向がある。これは個々の単語(unigram)だけでなく、\(n\)-gram に対しても成り立つ。

9.2.7. 演習

  1. この節の実験では、テキストを単語にトークン化し、Vocab インスタンスの min_freq 引数の値を変えてみよ。min_freq の変化が、結果として得られる語彙サイズにどのように影響するかを定性的に述べよ。

  2. このコーパスにおける unigram、bigram、trigram の Zipf 分布の指数を推定せよ。

  3. 他のデータソースを見つけよ(標準的な機械学習データセットをダウンロードする、別のパブリックドメインの本を選ぶ、Web サイトをスクレイピングする、など)。それぞれについて、単語レベルと文字レベルの両方でデータをトークン化せよ。min_freq の同じ値に対して、『タイムマシン』 コーパスと語彙サイズを比較するとどうなるか。これらのコーパスに対する unigram と bigram の分布に対応する Zipf 分布の指数を推定せよ。『タイムマシン』 コーパスで観測した値と比べてどうか。