自然なラテン語のようで、でもどこにも存在しない単語。
この不思議な響きを再現したくて、小さな言葉生成ツールを作りました。

コーパス(大量の古典ラテン語テキスト)を読み込み、文字の並びの確率から新しい単語を作る仕組みです。
今回はその開発を通して考えたこと、そしてRustでの実装の工夫についてまとめます。
単語の「らしさ」を数で表す
「単語を作る」というと、ランダムに文字を並べればよさそうに思えます。
しかし、たとえば英語なら「th」や「ing」のように、特定の並びが頻繁に現れます。
逆に「zx」や「qg」はほとんど出てきません。
このような“出やすさ”を確率で学習し、そこから新しい文字を選んでいくのがマルコフ連鎖(Markov Chain)という考え方です。
このツールでは、2文字(ビグラム bi-gram)を単位に確率を学習しています。
つまり、「前の2文字が ‘th’ のとき、その次に来やすい文字は何か?」を数え、表にしておくのです。
英語の“リズム”を確率で再現する、というイメージです。
学習データをどう読むか
学習のもとになるのは、テキストファイルです。
プログラムはこのファイルを開いて、一文字ずつ調べていきます。
英字はすべて小文字にし、英字以外の文字(空白や記号など)は単語の切れ目とみなします。
文の始まりは特別な記号 ^ で示し、単語の終わりは空白 ' ' で表現します。
この処理をRustで書くと、次のようになります。
fn build_counts(text: &str) -> HashMap<(char, char), HashMap<char, u64>> {
let mut counts = HashMap::new();
let mut p1 = '^';
let mut p2 = '^';
for ch in text.chars() {
if ch.is_ascii_alphabetic() {
let c = ch.to_ascii_lowercase();
*counts.entry((p1, p2)).or_default().entry(c).or_insert(0) += 1;
p1 = p2;
p2 = c;
} else {
// 単語の終端を記録
if (p1, p2) != ('^', '^') {
*counts.entry((p1, p2)).or_default().entry(' ').or_insert(0) += 1;
}
p1 = '^';
p2 = '^';
}
}
counts
}Code language: JavaScript (javascript)
こうして得られた「次に来る文字の回数」を集計して、CSVに出力します。
各行は「prefix(前2文字), next(次の文字), count(出現回数), prob(確率)」の形式です。

確率計算の工夫
確率を求めるときには、単純に頻度を割り算すればよいのですが、
一度も出てこなかった組み合わせは確率がゼロになります。
それでは新しい単語を作るときに“動きが止まって”しまうため、
ラプラス平滑化(Laplace smoothing)を使っています。
すべての組み合わせに少しだけ「見たことがある」と仮定して、
ゼロを避ける仕組みです。
Rustではこのように記述しています。
let alpha = 1.0;
let total: f64 = nexts.values().sum::<u64>() as f64;
let denom = total + alpha * 27.0; // 26文字 + 終端
for c in ('a'..='z').chain(std::iter::once(' ')) {
let cnt = *nexts.get(&c).unwrap_or(&0) as f64;
let prob = (cnt + alpha) / denom;
}Code language: PHP (php)
この考え方で、出現したことのない文字も少しだけ可能性を持つようになります。
まるで人間の想像力に“遊び”を与えるような仕組みです。
生成の仕組み:確率で文字を選ぶ
学習が終わると、次は単語の生成です。
2文字を手がかりに次の文字を確率的に選び、空白が出たら単語の終わりとします。
この確率サンプリングには、rand クレートの WeightedIndex を使いました。
Rustでは乱数処理も型安全で書けるので、安心して確率分布を扱えます。
fn gen_next<R: Rng + ?Sized>(
rng: &mut R,
table: &HashMap<(char, char), (Vec<char>, Vec<f64>)>,
prefix: (char, char)
) -> Option<char> {
let (choices, weights) = table.get(&prefix)?;
let dist = WeightedIndex::new(weights).ok()?;
Some(choices[dist.sample(rng)])
}Code language: JavaScript (javascript)
こうして選ばれた文字をつなげていくと、「英語っぽいけれど存在しない単語」が生まれます。
単語の“進化”:削除と挿入で遊ぶ
もうひとつの特徴が「変容(トランスフォーム)」機能です。
生成した単語を、1文字だけ削除したり、1文字挿入したりできます。
これを繰り返すと、単語が少しずつ姿を変えていきます。
生物の突然変異のようなイメージです。
削除や挿入の位置は完全にランダムではありません。
「どの位置が不自然か」を確率から逆算し、
“不自然な位置ほど変化しやすい”ように設計しています。
fn score_delete(table: &Table, word: &[char], i: usize) -> f64 {
let p_in = prob_next(table, prev2(word, i), word[i]);
let p_out = prob_next(table, (word[i-1], word[i]), word.get(i+1).copied().unwrap_or(' '));
-((p_in * p_out).max(EPSILON)).ln()
}Code language: JavaScript (javascript)
このスコアを重みとして、削除位置を WeightedIndex で抽選します。
結果として、単語の“自然な揺らぎ”を再現できるようになりました。
対話型REPLでの遊び方
ツールはコマンドラインから対話的に使えます。
起動すると、次のような入力を受け付けます。
@ 新しい単語をゼロから生成
seed その文字列をもとに補完して単語を完成
seed- その文字列をもとに1文字削除
seed+ その文字列をもとに1文字挿入
seed! 削除か挿入をランダムに
(空行) 直前の単語に!を適用
プログラムが出力する単語はどれも初めて見るのに、
どこか「ラテン語らしさ」を感じさせます。
advv
advov
advovi
adveovi
advebovi
avebovi
aviebovi
avebovi
avebuovi
aveuovi
aveuodvi
終わりに:確率が作る“らしさ”
英単語のような音の並びには、統計的な規則があります。
それを学び、確率の形で再現すると、「らしさ」が浮かび上がります。
単なる乱数ではなく、言葉の背後にある“パターンの手触り”を掴む試みでした。
Rustという厳密な言語で作ることで、その秩序と偶然の境界がよりくっきり見えてきます。