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 カテゴリなどでの実験もできるかもしれない。
参考文献
-
Improving Language Understanding
by Generative Pre-Training (GPT) - Language Models are Unsupervised Multitask Learners (GPT-2)
- gpt-2(リファレンス実装)
- Wikipedia: ハルシネーション (人工知能)
Discussion