架空のラテン語風の単語を生み出す──
Rustで作ったマルコフ連鎖ジェネレーター

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

<svg class="eyecatch-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
  <!-- 背景:白の角丸四角形 -->
  <rect x="8" y="8" width="176" height="176" rx="32" ry="32" fill="#FFFFFF" />

  <!-- 下部プレート(ライブラリ感の土台) -->
  <g transform="translate(20,148)">
    <rect x="0" y="0" width="152" height="24" rx="6" ry="6" fill="#E0E0E0" />
  </g>

  <!-- マルコフ遷移ノード&エッジ -->
  <g transform="translate(0,0)">
    <!-- エッジ(矢印) -->
    <!-- 左 -> 中央 -->
    <path d="M56 96 C72 96, 80 96, 96 96" fill="none" stroke="#2196F3" stroke-width="4" />
    <!-- 矢印頭(左->中央) -->
    <path d="M92 92 L96 96 L92 100" fill="none" stroke="#2196F3" stroke-width="4" />

    <!-- 中央 -> 右 -->
    <path d="M96 96 C112 96, 120 96, 136 96" fill="none" stroke="#2196F3" stroke-width="4" />
    <!-- 矢印頭(中央->右) -->
    <path d="M132 92 L136 96 L132 100" fill="none" stroke="#2196F3" stroke-width="4" />

    <!-- 自己遷移(中央) -->
    <path d="M96 72 C116 72, 116 88, 96 88" fill="none" stroke="#2196F3" stroke-width="3" />
    <!-- 自己遷移矢印頭 -->
    <path d="M98 84 L96 88 L94 84" fill="none" stroke="#2196F3" stroke-width="3" />

    <!-- ノード(左:START的) -->
    <g>
      <circle cx="56" cy="96" r="16" fill="#FFFFFF" stroke="#4CAF50" stroke-width="4" />
      <circle cx="56" cy="96" r="6" fill="#4CAF50" />
    </g>

    <!-- ノード(中央:中間状態) -->
    <g>
      <circle cx="96" cy="96" r="18" fill="#FFFFFF" stroke="#2196F3" stroke-width="4" />
      <circle cx="96" cy="96" r="8" fill="#2196F3" />
    </g>

    <!-- ノード(右:END的) -->
    <g>
      <circle cx="136" cy="96" r="16" fill="#FFFFFF" stroke="#9C27B0" stroke-width="4" />
      <circle cx="136" cy="96" r="6" fill="#9C27B0" />
    </g>
  </g>

  <!-- ランダム性の表現:右上の小さなサイコロ -->
  <g transform="translate(132,24)">
    <rect x="0" y="0" width="32" height="32" rx="6" ry="6" fill="#FF9800" stroke="#FF9800" stroke-width="2" />
    <circle cx="10" cy="10" r="3" fill="#FFFFFF" />
    <circle cx="22" cy="22" r="3" fill="#FFFFFF" />
  </g>
</svg>
架空のラテン語風の単語を生み出す──<br class="chiilabo-br is-on">Rustで作ったマルコフ連鎖ジェネレーター

コーパス(大量の古典ラテン語テキスト)を読み込み、文字の並びの確率から新しい単語を作る仕組みです。
今回はその開発を通して考えたこと、そしてRustでの実装の工夫についてまとめます。

関連記事

1. 単語の「らしさ」を数で表す

「単語を作る」というと、ランダムに文字を並べればよさそうに思えます。
しかし、たとえば英語なら「th」や「ing」のように、特定の並びが頻繁に現れます。
逆に「zx」や「qg」はほとんど出てきません。
このような“出やすさ”を確率で学習し、そこから新しい文字を選んでいくのがマルコフ連鎖(Markov Chain)という考え方です。

マルコフ連鎖による単語の進化 avebuovi aveuovi aveuodvi naveuodvi 1文字削除 1文字挿入 繰り返すことで単語が少しずつ変化 確率で次の文字を選ぶ 前2文字 dv 次の文字 o 70% chiilabo.jp

このツールでは、2文字(ビグラム bi-gram)を単位に確率を学習しています。
つまり、「前の2文字が ‘th’ のとき、その次に来やすい文字は何か?」を数え、表にしておくのです。
英語の“リズム”を確率で再現する、というイメージです。

1.1. 学習データをどう読むか

学習のもとになるのは、テキストファイルです。
プログラムはこのファイルを開いて、一文字ずつ調べていきます。
英字はすべて小文字にし、英字以外の文字(空白や記号など)は単語の切れ目とみなします。
文の始まりは特別な記号 ^ で示し、単語の終わりは空白 ' ' で表現します。

この処理を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(確率)」の形式です。

1.1. 学習データをどう読むか

1.2. 確率計算の工夫

確率を求めるときには、単純に頻度を割り算すればよいのですが、
一度も出てこなかった組み合わせは確率がゼロになります。
それでは新しい単語を作るときに“動きが止まって”しまうため、
ラプラス平滑化(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)

この考え方で、出現したことのない文字も少しだけ可能性を持つようになります。
まるで人間の想像力に“遊び”を与えるような仕組みです。

1.3. 生成の仕組み:確率で文字を選ぶ

学習が終わると、次は単語の生成です。
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)

こうして選ばれた文字をつなげていくと、「英語っぽいけれど存在しない単語」が生まれます。

2. 単語の“進化”:削除と挿入で遊ぶ

もうひとつの特徴が「変容(トランスフォーム)」機能です。
生成した単語を、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 で抽選します。
結果として、単語の“自然な揺らぎ”を再現できるようになりました。

2.1. 対話型REPLでの遊び方

ツールはコマンドラインから対話的に使えます。
起動すると、次のような入力を受け付けます。

@    新しい単語をゼロから生成
seed   その文字列をもとに補完して単語を完成
seed-  その文字列をもとに1文字削除
seed+  その文字列をもとに1文字挿入
seed!  削除か挿入をランダムに
(空行) 直前の単語に!を適用

プログラムが出力する単語はどれも初めて見るのに、
どこか「ラテン語らしさ」を感じさせます。

advv
advov
advovi
adveovi
advebovi
avebovi
aviebovi
avebovi
avebuovi
aveuovi
aveuodvi

3. 終わりに:確率が作る“らしさ”

英単語のような音の並びには、統計的な規則があります。
それを学び、確率の形で再現すると、「らしさ」が浮かび上がります。
単なる乱数ではなく、言葉の背後にある“パターンの手触り”を掴む試みでした。
Rustという厳密な言語で作ることで、その秩序と偶然の境界がよりくっきり見えてきます。