🦜

Transformer に触れてみる (4) — GPT-2 もどきで“ハルシネーション的”な現象を観察

に公開

目的

前回 Transformer に触れてみる (3) — GPT-2 もどき で GPT-2 のミニ版を作ってみた。

さて、これは LLM とは呼べないのだが、LLM だと思うことにしよう。LLM と言えばハルシネーションだ。これを体験してみたい。なお、ここで「ハルシネーション」とは Wikipedia: ハルシネーション (人工知能) に解説があるような

人工知能によって生成された、虚偽または誤解を招く情報を事実かのように提示する応答のこと

を意図している。

なお、これだけ小さなモデルでそれっぽいことをやるのは難しかったので、結構苦しい結果にはなってしまったと思う。また、本来のハルシネーションは、学習済みの知識の範囲内で事実と異なる情報をもっともらしく生成する現象を指すが、本記事では「未知のカテゴリに対しても何かしら出力しようとする」モデルの性質を観察する形とした。

作ったもの

  • タスクの内容: "EN,app" をプロンプトで渡すと、英単語として「apple」のようなものが、"JA,to" をプロンプトで渡すと、日本語の単語として「tokyo」のようなものが返ってくる。要するに、2 文字の言語コードの後ろにカンマを入れて、単語の出だしを見せるというものだ。

MiniGPT2 そのものの技術的な解説は Transformer に触れてみる (3) — GPT-2 もどき に譲る。

再び Vibe coding

今回も GPT-4.1 にやらせるが、前回の MiniGPT2 自体は使いまわして、データセットとプロンプトのフォーマットだけを変えることにする。

実装

データセット周りと生成用の関数以外は Transformer に触れてみる (3) — GPT-2 もどき と同じである。「★」で変更した部分を示す。

準備やデータセット

以下は変更なし。

!pip install -qU bertviz

以下は変更なし。

from __future__ import annotations

import json
import random
import string
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import numpy as np


SEED = 42
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
if torch.cuda.is_available():
    torch.cuda.manual_seed_all(SEED)

★ データセット周りはテコ入れした。

# GPT に適当に 1000 個くらい作らせる。
ENGLISH_WORDS = [
    "ability", "absence", "academy", "access", "accident", "account", "achievement", "acid", "acquisition", "action",
    "activity", "actor", "actress", "addition", "address", "administration", "admission", "adoption", "adult", "adventure",
    "advertisement", "advice", "affair", "affection", "agency", "agenda", "agreement", "aid", "aim", "air",
    "aircraft", "airline", "airport", "alarm", "album", "alcohol", "alert", "alien", "alley", "alliance",
  ...
]  # 長いので割愛

# GPT に適当に 1000 個くらい作らせる。
JAPANESE_WORDS_ROMAJI = [
    "ai", "aikido", "aisu", "ajisai", "aka", "akachan", "akane", "akari", "akushu", "ama",
    "ame", "amerika", "amida", "ana", "ani", "aniki", "ankou", "anone", "anpan", "anshin",
    "ansoku", "ao", "aoi", "aomori", "aosu", "arai", "arashi", "arigato", "ari", "arisa",
    "arisu", "aruba", "aruku", "asahi", "asakusa", "asari", "ase", "ashita", "ashi", "ashita",
    "ashiyu", "asobu", "asoko", "asoko", "atarashii", "atsui", "atsumaru", "atsume", "ato", "atsui",
    "awa", "awabi", "awase", "awatenbou", "aya", "ayame", "ayumu", "azuki", "azuma", "azunari",
    "bai", "baika", "bairin", "baiu", "baka", "bakudan", "baku", "bakufu", "bamboo", "bando",
  ...
]  # 長いので割愛


# 1. データセット用:英単語+日本語単語で各1000個前後ずつ
# 先頭にカテゴリラベルを付与
LABELED_NAMES = []
for w in ENGLISH_WORDS:
    LABELED_NAMES.append(f"EN,{w}")
for w in JAPANESE_WORDS_ROMAJI:
    LABELED_NAMES.append(f"JA,{w}")

NAMES = LABELED_NAMES

# 2. 文字のボキャブラリ作成
# 学習では未実施の言語コード FR(フランス語), IT(イタリア語), MX(メキシコ語)を追加。
ALL_CHARS = sorted(set("".join(NAMES)))
SPECIAL_TOKENS = ["<PAD>", "<BOS>", "<EOS>"]
ALL_TOKENS = SPECIAL_TOKENS + ALL_CHARS + sorted(set("".join(["FR", "IT", "MX"])))
VOCAB_SIZE = len(ALL_TOKENS)
CHAR2IDX = {ch: i for i, ch in enumerate(ALL_TOKENS)}
IDX2CHAR = {i: ch for ch, i in CHAR2IDX.items()}
PAD_IDX = CHAR2IDX["<PAD>"]
BOS_IDX = CHAR2IDX["<BOS>"]
EOS_IDX = CHAR2IDX["<EOS>"]

以下は変更なし。

def encode_word(word, max_len):
    tokens = [BOS_IDX] + [CHAR2IDX[c] for c in word] + [EOS_IDX]
    tokens += [PAD_IDX] * (max_len - len(tokens))
    return tokens

def decode_tokens(tokens):
    chars = []
    for idx in tokens:
        if idx == EOS_IDX:
            break
        if idx >= len(IDX2CHAR):
            continue
        ch = IDX2CHAR[idx]
        if ch not in SPECIAL_TOKENS:
            chars.append(ch)
    return "".join(chars)

# 3. PyTorch Dataset
class NameDataset(Dataset):
    def __init__(self, words, max_len):
        self.max_len = max_len
        self.data = []
        for w in words:
            tokens = encode_word(w, max_len)
            self.data.append(tokens)
    def __len__(self):
        return len(self.data)
    def __getitem__(self, idx):
        tokens = self.data[idx]
        x = torch.tensor(tokens[:-1], dtype=torch.long)
        y = torch.tensor(tokens[1:], dtype=torch.long)
        return x, y
# 4. シンプルな位置エンコーディング
class PositionalEncoding(nn.Module):
    def __init__(self, d_model, max_len):
        super().__init__()
        pe = torch.zeros(max_len, d_model)
        position = torch.arange(0, max_len).unsqueeze(1)
        div_term = torch.exp(torch.arange(0, d_model, 2) * (-np.log(10000.0) / d_model))
        pe[:, 0::2] = torch.sin(position * div_term)
        if d_model > 1:
            pe[:, 1::2] = torch.cos(position * div_term)
        self.register_buffer('pe', pe)
    def forward(self, x):
        # x + self.pe[:x.size(1)].unsqueeze(0)
        return x + self.pe[:x.size(1)]

# 5. MiniGPT2本体(Pre-LN)
class MiniGPT2(nn.Module):
    def __init__(self, vocab_size, d_model=32, max_len=16):
        super().__init__()
        self.d_model = d_model
        self.embed = nn.Embedding(vocab_size, d_model)
        self.pos_enc = PositionalEncoding(d_model, max_len)
        # Self-Attention (single head, 1層)
        # ★ バイアス項はなし。詳細な理由は後述。
        self.q_linear = nn.Linear(d_model, d_model, bias=False)
        self.k_linear = nn.Linear(d_model, d_model, bias=False)
        self.v_linear = nn.Linear(d_model, d_model, bias=False)
        self.attn_out = nn.Linear(d_model, d_model, bias=False)
        # FFN
        # ★ バイアス項はなし。詳細な理由は後述。
        self.ffn = nn.Sequential(
            nn.Linear(d_model, d_model, bias=False),
            nn.ReLU(),
            nn.Linear(d_model, d_model, bias=False)
        )
        self.ln1 = nn.LayerNorm(d_model)
        self.ln2 = nn.LayerNorm(d_model)
        self.max_len = max_len
        self.attn_weights = None

    def forward(self, x, return_attn=False):
        emb = self.embed(x)
        emb = self.pos_enc(emb)
        # ★ Pre-LN構造:LN→Attention→Add
        attn_in = self.ln1(emb)
        Q = self.q_linear(attn_in)
        K = self.k_linear(attn_in)
        V = self.v_linear(attn_in)
        scores = torch.matmul(Q, K.transpose(-2, -1)) / np.sqrt(self.d_model)
        # causal mask
        mask = torch.triu(torch.ones(scores.size(-2), scores.size(-1)), diagonal=1).bool().to(x.device)
        scores = scores.masked_fill(mask, float('-inf'))
        attn = torch.softmax(scores, dim=-1)
        attn_out = torch.matmul(attn, V)
        attn_out = self.attn_out(attn_out)
        # ★ self.ln1 は Pre-LN構造に移動
        x1 = emb + attn_out
        # ★ Pre-LN→FFN→Add
        ffn_in = self.ln2(x1)
        x2 = x1 + self.ffn(ffn_in)
        # ★ weight tying
        logits = torch.matmul(x2, self.embed.weight.t())
        if return_attn:
            self.attn_weights = attn.detach().cpu().numpy()
            return logits, attn
        return logits

    def generate(self, start_tokens, eos_idx, pad_idx, max_gen=None, temperature=1.0):
        self.eval()
        max_gen = max_gen or self.max_len
        tokens = start_tokens.tolist()
        for _ in range(max_gen - len(tokens)):
            inp = torch.tensor(tokens, dtype=torch.long).unsqueeze(0).to(next(self.parameters()).device)
            logits = self.forward(inp)
            next_token_logits = logits[0, len(tokens)-1] / temperature
            probs = torch.softmax(next_token_logits, dim=-1)
            next_token = torch.multinomial(probs, num_samples=1).item()
            if next_token == eos_idx:
                break
            tokens.append(next_token)
        while len(tokens) < self.max_len:
            tokens.append(pad_idx)
        return tokens

訓練ループ

以下は変更なし。

# 6. 訓練ループ
def train(model, loader, optimizer, criterion, device):
    model.train()
    total_loss = 0
    for x, y in loader:
        x = x.to(device)
        y = y.to(device)
        optimizer.zero_grad()
        logits = model(x)
        loss = criterion(logits.view(-1, VOCAB_SIZE), y.view(-1))
        loss.backward()
        optimizer.step()
        total_loss += loss.item()
    return total_loss / len(loader)

def evaluate(model, loader, criterion, device):
    model.eval()
    total_loss = 0
    with torch.no_grad():
        for x, y in loader:
            x = x.to(device)
            y = y.to(device)
            logits = model(x)
            loss = criterion(logits.view(-1, VOCAB_SIZE), y.view(-1))
            total_loss += loss.item()
    return total_loss / len(loader)

学習

以下は変更なし。

%%time

# 設定
max_word_len = max(len(w) for w in NAMES) + 2 # BOS, EOS
batch_size = 16
d_model = 32
n_epochs = 100
device = "cuda" if torch.cuda.is_available() else "cpu"

# データ分割
random.seed(42)
random.shuffle(NAMES)
split = int(len(NAMES) * 0.8)
train_words = NAMES[:split]
test_words = NAMES[split:]

train_ds = NameDataset(train_words, max_word_len)
test_ds = NameDataset(test_words, max_word_len)
train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True)
test_loader = DataLoader(test_ds, batch_size=batch_size, shuffle=False)

# モデル
model = MiniGPT2(VOCAB_SIZE, d_model, max_word_len).to(device)
optimizer = optim.Adam(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss(ignore_index=PAD_IDX)

# 学習
for epoch in range(1, n_epochs+1):
    train_loss = train(model, train_loader, optimizer, criterion, device)
    test_loss = evaluate(model, test_loader, criterion, device)
    if epoch % 5 == 0:
        print(f"Epoch {epoch:2d}: train loss={train_loss:.4f}, test loss={test_loss:.4f}")

Epoch 5: train loss=1.8908, test loss=1.8234
...
Epoch 100: train loss=1.4950, test loss=1.5617
CPU times: user 37 s, sys: 949 ms, total: 37.9 s
Wall time: 40.1 s

生成例

英単語と日本語単語に加え、学習させていないイタリア語の単語の生成を試みてみよう。勿論スクラッチで訓練しているので、MiniGPT2 はイタリア語の概念は何も持ち合わせていない。

以下に 5 つの生成例を提示する。

# 7. 単語生成
# ★ ラベルとして言語コードを受け付ける形に拡張してある。
def sample_generate(label, prefix, model, max_word_len, temperature=1.0):
    # label: "EN", "JA"
    # prefix: "ko", "mer" など
    prompt = f"{label},{prefix}"
    start_tokens = [BOS_IDX] + [CHAR2IDX[c] for c in prompt]
    start_tokens = torch.tensor(start_tokens, dtype=torch.long)
    out_tokens = model.generate(start_tokens, eos_idx=EOS_IDX, pad_idx=PAD_IDX, max_gen=max_word_len, temperature=temperature)
    # BOS, ラベル, カンマ, prefix を除いて生成単語部分のみを返す場合:
    return decode_tokens(out_tokens[1:])

print("生成例(英単語 ko):", sample_generate("EN", "ko", model, max_word_len, temperature=0.8))
print("生成例(日本語単語 ko):", sample_generate("JA", "ko", model, max_word_len, temperature=0.8))
print("生成例(イタリア語単語 ko):", sample_generate("IT", "ko", model, max_word_len, temperature=0.8))

生成例(英単語 ko): EN,koukement
生成例(日本語単語 ko): JA,kowate
生成例(イタリア語単語 ko): IT,koittiiii

※ ↑「イタリア語の単語で ko で始まるものをおしえて」「はい、例えば koittiiii などです!」をシミュレートしているつもりである。ここでの「イタリア語(IT)」のような “学習していないカテゴリ” に対する出力は、厳密には「ハルシネーション」とは少し異なる。本来のハルシネーションは、学習済みの知識の範囲内で事実と異なる情報をもっともらしく生成する現象を指すが、本記事の例は「未知のカテゴリに対しても何かしら出力しようとする」モデルの性質を観察したものとなる。

print("生成例(英単語 end):", sample_generate("EN", "end", model, max_word_len, temperature=0.8))
print("生成例(日本語単語 end):", sample_generate("JA", "end", model, max_word_len, temperature=0.8))
print("生成例(イタリア語単語 end):", sample_generate("IT", "end", model, max_word_len, temperature=0.8))

生成例(英単語 end): EN,endle
生成例(日本語単語 end): JA,endoutsu
生成例(イタリア語単語 end): IT,endd

print("生成例(英単語 mer):", sample_generate("EN", "mer", model, max_word_len, temperature=0.8))
print("生成例(日本語単語 mer):", sample_generate("JA", "mer", model, max_word_len, temperature=0.8))
print("生成例(イタリア語単語 mer):", sample_generate("IT", "mer", model, max_word_len, temperature=0.8))

生成例(英単語 mer): EN,merbit
生成例(日本語単語 mer): JA,meri
生成例(イタリア語単語 mer): IT,merchu

print("生成例(英単語 dog):", sample_generate("EN", "dog", model, max_word_len, temperature=0.8))
print("生成例(日本語単語 dog):", sample_generate("JA", "dog", model, max_word_len, temperature=0.8))
print("生成例(イタリア語単語 dog):", sample_generate("IT", "dog", model, max_word_len, temperature=0.8))

生成例(英単語 dog): EN,dogne
生成例(日本語単語 dog): JA,dogio
生成例(イタリア語単語 dog): IT,dogu

print("生成例(英単語 pur):", sample_generate("EN", "pur", model, max_word_len, temperature=0.8))
print("生成例(日本語単語 pur):", sample_generate("JA", "pur", model, max_word_len, temperature=0.8))
print("生成例(イタリア語単語 pur):", sample_generate("IT", "pur", model, max_word_len, temperature=0.8))

生成例(英単語 pur): EN,purmp
生成例(日本語単語 pur): JA,purinton
生成例(イタリア語単語 pur): IT,purtsiiiipppppp

のような結果になった。英単語は「~ment」とか「~dle」とか、何となくそれっぽい英単語風の綴りや音韻が見られるような気がする。
日本語単語は、どれも仮名表記ができそうな日本語っぽい音節・接尾語が見られるような気がする。プリントンって何だろう?ってのはあるけど。イタリア語単語は koittiiii とか purtsiiiipppppp とか、相当に混乱してしまった出力も見られる。

考察

英語もイタリア語もラテン語からの由来の単語がそこそこあるはずで、GPT に列挙させると以下のような似たような雰囲気の単語があるはずである。

英語 イタリア語
animal animale
original originale
memory memoria
university università
system sistema
region regione
radio radio
culture cultura
piano pianoforte

しかし上記の生成例ではイタリア語の単語にはそういう傾向はあまり見られない気がする(学習させていないから)。LLM に限らず、AI モデルには「知らない」「分からない」ということを学習させないと、何かの出力を出そうとするので、こういうことが起きると考えている。何となく英単語のような風合いを持ちつつも、何となく日本語単語のような風合いを持つという、データセットで見たような何かっぽいものと、後はパターンの中にはなかった謎の語尾の繰り返し。

これをもって「ハルシネーション的な現象」と言ってしまうのもどうかとは思うのだが、今回はこういう無理な出力で取り繕ってしまった出力をもってハルシネーション的な現象を得たとしたい。

まとめ

思ったより苦しい実験にはなったが、GPT などの LLM におけるハルシネーション的な現象を観察できたように思う。

実は、これ以上複雑なタスクにすると、生成結果があまり明瞭でなくなり、良い体験が出来なかったので 2 種類のカテゴリに留めた。コンセプト的に MiniGPT2 はシンプルさを優先し、Attention はシングルヘッド、1 層のみとしているが、ここをもう少しスケールさせたら 3 カテゴリなどでの実験もできるかもしれない。

参考文献

GitHubで編集を提案

Discussion