はじめに
前回、モウラの制約を意識して、発音可能な無意味カナ文字列を生成しましたが、2文字モウラが多すぎたり少なかったりして、あまり日本語っぽくないものができてしまいました。
そこで、日本語コーパスからモウラの出現頻度を計算して、より日本語らしい無意味カナ文字列の生成を試みます。
準備
コーパス
モウラの出現頻度を計算するコーパスとして、livedoor_news_corpusを使用します。
すべてのsplitのcontentを取得します。
import datasets
def get_livedoor_texts() -> list[str]:
dataset = load_dataset("shunk031/livedoor-news-corpus", split=None)
all_texts = []
for split in ["train", "validation", "test"]:
for item in dataset[split]:
all_texts.append(item["content"])
return all_texts
発音カナ
次にコーパスの発音カナを得ます。
pyopenjtalk-plusを使います。音声合成用に辞書が充実しており、MeCabやSudachiなどよりも正確に発音カナを取得できると期待されます。
全体を処理する前にpyopenjtalk-plusの挙動を確認しておきます。
import pyopenjtalk
print(pyopenjtalk.g2p("今日はりんごを食べに奈良へ", kana=True))
print(pyopenjtalk.g2p("ああ、かあ、いい、えぇ、りんごお、やっていく、ホエイプロテイン、ほえいぷろていん", kana=True))
print(pyopenjtalk.g2p("ゑ", kana=True))
print(pyopenjtalk.g2p("気づき、ぢ", kana=True))
print(pyopenjtalk.g2p("共栄、講師、子牛、", kana=True))
print(pyopenjtalk.g2p("12345、10回、13", kana=True))
print(pyopenjtalk.g2p("English、Hello、total、quiet、ABD、D、ABC、EFG、ABCDEFGHIJKLMNOPQRSTUVWXYZ", kana=True))
キョーワリンゴヲショクベニナラエ
アー、カア、イイ、エェ、リンゴー、ヤッテイク、ホエイプロテイン、ホエイプロテイン
ヱ
キズキ、ジ
キョーエー、コーシ、コウシ、
イチマンニセンサンビャクヨンジューゴ、ジュッカイ、ジューサン
イングリッシュ、ハロー、トータル、キューユーアイイーティー、エイビーD、D、エイビーシー、イーエフジー、Aビーシーディーイーエフジーエイチアイジェーケーエルエムエヌオーピーキューアールエスティーユーブイダブリューエックスワイゼット
- 読点など記号はそのまま
- 助詞の「は」「へ」は発音「ワ」「エ」になる。「を」は「ヲ」(not「オ」)になる
- ひらがなやカタカナはそのままカタカナになることが多い。「ああ」->「アー」のように母音が長音に変換される場合もある。おそらく「ああ」が辞書に登録されていたのだと思われる。
- 旧仮名はそのまま
- 「づ」「ぢ」は「ズ」「ジ」になる。
- 漢字は辞書に登録されているものは母音よりも長音が優先されることが多そう。ただし熟語による。
- 数字は後ろに単位が付く場合なども含めていい感じにしてくれる。
- 英語は辞書登録があるものはカタカナ英語に変換される。辞書にないものはアルファベットがそのままよまれる。ときどきアルファベットがそのまま残るケースがある。発生条件不明。
みたいな傾向が見てとれました。
記号やアルファベットなど一部変換されずにそのまま残るものがあるので、カタカナのみ取り出す処理はしたほうがよさそうです。
「ヲ」は「オ」に変換するなどその他の後処理については、あとで考えます。
def text_to_kana(text: str) -> list[str]:
"""
Convert Japanese text to katakana using pyopenjtalk.
Args:
text (str): Input Japanese text
Returns:
str: Converted katakana string
"""
kana = pyopenjtalk.g2p(text, kana=True)
# カナの塊(カタカナのみ)をすべて抽出
kana_blocks = re.findall(r"[ァ-ンヴー]+", kana)
return kana_blocks
実行してみると以下のような結果になります。
print(text_to_kana("今日はりんごを、食べに奈良へ。Hello!"))
['キョーワリンゴヲ', 'ショクベニナラエ', 'ハロー']
「食べに」が「タベニ」ではなく「ショクベニ」となってしまっていますが、多少のミスは仕方ないものとして許容します。おそらく無視できる頻度だと思います。
前処理
pyopenjtalk.g2pは2700文字前後ぐらいが一度に変換できる限界の長さらしかったので、指定した文字数以下に分割する処理を書きました。単純なスプリットと比べると以下の工夫をしています。
- 関数内でハードコーディングしたseparator(発音の区切りになりそうな記号)の箇所のみ切れ目となりうる
- 連続するseparatorは1つとして扱う
- separatorは読点に変換され、各分割に残す(のちの工程で利用する)
- 末尾のseparatorは削除する
import pyopenjtalk
import re
from collections import Counter
def split_text(text: str, length: int) -> list[str]:
"""
Split text into chunks of a specified length.
Args:
text (str): Input text
length (int): Length of each chunk
Returns:
list[str]: List of text chunks
"""
# AIDEV-NOTE: textをスペースか記号で分割
tokens = re.split(r"[\s 、。!?\n\r\t,.!?;:・「」()【】『』…―—]+", text)
tokens = [t+"、" for t in tokens if t]
tokens[-1] = tokens[-1][:-1]
chunks = []
current = ""
for token in tokens:
if not token:
continue
# tokenがlengthを超える場合はそのままchunkに追加
if len(token) > length:
if current:
chunks.append(current)
current = ""
chunks.append(token)
continue
# 次のtokenを追加してもlengthを超えない場合はcurrentに追加
if len(current) + len(token) <= length:
current += token
else:
if current:
chunks.append(current)
current = token
if current:
chunks.append(current)
return chunks
以下のような実行結果になります。
print(split_text("今日はりんごを食べに奈良へ。Hello! こんにちは、さよーなら、", 10))
['今日はりんごを食べに奈良へ、', 'Hello、', 'こんにちは、', 'さよーなら']
後処理
後処理で同じ発音に対する表記揺れをなくしたいです。
例えば「ヲ」は「オ」に変えたいです。
他にも変えるべきものがあるかもしれないので、livedoor_news_corpusからモウラを取得して、前回作った発音の表記揺れをなくしたモウラのリストと比較してみます。
import jamorasep
import tqdm
sample_texts = get_livedoor_texts()
moras = set()
for text in tqdm.tqdm(sample_texts):
sentences = split_text(text, 2500)
for sentence in sentences:
kana_blocks = text_to_kana(sentence)
for block in kana_blocks:
moras.update(jamorasep.parse(block, output_format="katakana"))
reference_moras = set(pd.read_csv("mora.csv")["katakana"])
moras_not_in_reference = sorted(list(moras - reference_moras))
print("Moras not in reference mora.csv:")
print(moras_not_in_reference)
Moras not in reference mora.csv:
['クヮ', 'ヂ', 'ヅ', 'ヱ', 'ヲ']
モウラ分割
前節で見つかった表記揺れを補正してからモウラに分割します。
補正のタイミングは本当はカウントを集計してからのほうが効率的なのですが、実装が楽なので、モウラ分割の手前で行うことにします。
また、本当は、他のコーパスにも対応できるように、jamorasepの表記揺れすべてを吸収するmapを作ったほうが良いのですが、面倒なので妥協しています。
def split_to_moras(kana: str) -> list[str]:
"""
Split a katakana string into moras using jamorasep.
Args:
kana (str): Input katakana string
Returns:
list[str]: List of moras
"""
kanamap = {
'クヮ': 'クァ', 'ヂ': 'ジ', 'ヅ': 'ズ', 'ヱ': 'エ', 'ヲ': 'オ'
}
for src, target in kanamap.items():
kana = kana.replace(src, target)
return jamorasep.parse(kana, output_format="katakana")
以下のような実行結果になります。
print(split_to_moras("クヮクヮ、ヂヂ、ヅヅ、ヱヱ、ヲヲ"))
['クァ', 'クァ', '、', 'ジ', 'ジ', '、', 'ズ', 'ズ', '、', 'エ', 'エ', '、', 'オ', 'オ']
記号が残ってしまいますが、split_to_moraに入力する手前で除かれている前提のため、大丈夫です。
パイプライン
上記までの処理をつなげて、コーパスからモウラのリストを得る関数を作ります。
基本はつなげただけですが、前後ともともと記号があった場所に""という特殊トークンを挿入しています。bigramを求めるときに使います。
def corpus_to_moras(texts: list[str]) -> list[str]:
"""
Convert a list of texts to a list of mora lists.
Args:
texts (list[str]): List of input texts
Returns:
list[list[str]]: List of mora lists
"""
all_moras = ["<SEP>"]
for text in tqdm.tqdm(texts):
sentences = split_text(text, 2500)
for sentence in sentences:
kana_blocks = text_to_kana(sentence)
for block in kana_blocks:
moras = split_to_moras(block)
if moras:
all_moras += moras
all_moras.append("<SEP>")
return all_moras
実行してみると以下のような結果になります。
texts = ["こんにちは、さようなら", "ありがとう、またね"]
mora_lists = corpus_to_moras(texts)
print(mora_lists)
['<SEP>', 'コ', 'ン', 'ニ', 'チ', 'ワ', '<SEP>', 'サ', 'ヨ', 'ー', 'ナ', 'ラ', '<SEP>', 'ア', 'リ', 'ガ', 'ト', 'ー', '<SEP>', 'マ', 'タ', 'ネ', '<SEP>']
パイプラインの実行
コーパスに対してパイプライン処理を実行し、jsonとして保存しておきます。
import json
all_texts = get_livedoor_texts()
all_moras = corpus_to_moras(all_texts)
with open("livedoor_moras.json", "w") as f:
json.dump(all_moras, f)
unigram
まずはunigramの頻度を求めます。
頻度集計
準備で保存したmorasを読み込んで頻度を集計します。
<SEP>は不要なので除きます。計算結果はjsonで一応保存しておきます。
from collections import Counter
def mora_unigram_frequency(moras: list[str]) -> dict[str, int]:
"""
Calculate the frequency of each mora in the list.
Args:
moras (list[str]): List of moras
Returns:
dict[str, int]: Dictionary with mora as key and its frequency as value
"""
frequency = Counter(moras)
frequency.pop("<SEP>", None) # Remove <SEP> from frequency count
return dict(frequency)
with open("livedoor_moras.json", "r") as f:
all_moras = json.load(f)
mora_freq = mora_unigram_frequency(all_moras)
with open("livedoor_mora_unigram_frequency.json", "w") as f:
json.dump(mora_freq, f, ensure_ascii=False, indent=2)
できあがったjsonは以下のような感じです。
{
"ニ": 236663,
"セ": 108864,
"ン": 559358,
"ゴ": 47913,
"ネ": 41158,
"ジュ": 61018,
"ー": 912916,
"イ": 600594,
"チ": 99649,
...
無意味文字列生成
出現頻度から計算される出現確率に従ってカナ文字列を生成します。
- moraの出現数を確率に直す
- np.random.choiceで確率を指定して、ランダム生成
という手順です。
import numpy as np
def random_kana_by_freq(length: int, mora_unigram_frequency: dict[str, int]) -> str:
"""
Generate a random katakana sequence using mora_freq probabilities.
Args:
length (int): Length of the sequence
Returns:
str: Generated katakana sequence
Raises:
ValueError: If length is not positive
"""
if length <= 0:
raise ValueError(f"Expected positive integer, got {length}")
moras = list(mora_unigram_frequency.keys())
probs = np.array([mora_unigram_frequency[m] for m in moras], dtype=np.float64)
probs /= probs.sum() # normalize
sequence = "".join(np.random.choice(moras, size=length, p=probs))
return sequence
実行してみると以下のような結果になります。
# 例: 32文字のランダムカナ文字列
random_kana_by_freq(32, mora_freq)
'ータンルエツエモカナデーキンイルルトワアミタシーテテナルエビガワ'
長音で始まってはいますが、全体的には前回よりも日本語らしい感じはします。
ただ「ルル」など「テテ」など同じ文字の連続が、ちょっと多い気がします。同じ文字の連続はあってもいいですが、どちらかというとレアな事象であってほしい気がします。
bigram
日本語らしさはモウラ単体の頻度よりも、前後のモウラのつながりによって得られる気がします。
そこでbigramの分布を使って文字列を生成してみます。
頻度集計
bigramで頻度集計します。
<SEP>はpauseに近い意味と捉えていますが、pauseの前後に登場しやすいモウラかどうかの情報も重要なため、<SEP>も含めたbigramを計算します。
def mora_bigram_frequency(moras: list[str]) -> dict[str, dict[str, int]]:
"""
Calculate the frequency of each mora bigram in a list of Japanese texts.
Uses a special token <SEP> for text boundaries and handles chunk boundaries.
Args:
texts (list[str]): List of Japanese texts
Returns:
dict[tuple[str, str], float]: Dictionary of mora bigrams and their appearance rate
"""
# Generate bigrams
bigrams = []
for i in range(len(moras) - 1):
bigram = (moras[i], moras[i + 1])
bigrams.append(bigram)
bigram_freq = Counter(bigrams)
# Convert to probabilities
result_dict = {}
for (m1, m2), count in bigram_freq.items():
if m1 not in result_dict:
result_dict[m1] = {}
result_dict[m1][m2] = count
return result_dict
bigram_freq = mora_bigram_frequency(all_moras)
with open("livedoor_mora_bigram_freqeuncy.json", "w") as f:
json.dump(bigram_freq, f, ensure_ascii=False, indent=2)
保存されたファイルの中身は以下のような感じです。
bigramなので2階層になっています。
{
"<SEP>": {
"ニ": 25970,
"ナ": 15342,
"ツ": 8672,
"ヨ": 10347,
"キ": 10496,
"ソ": 25189,
"ゲ": 4570,
"ディ": 3816,
"ブ": 5038,
"サ": 25038,
"ア": 32910,
...
出現確率
文字列生成のまえに、出現確率を求めておきます。
unigramの場合よりも少しややこしいです。
from collections import defaultdict
# Additional utility function to get conditional probabilities
def get_conditional_probabilities(bigram_freq: dict[str, dict[str, int]]) -> dict[str, dict[str, float]]:
"""
Convert bigram frequencies to conditional probabilities P(w2|w1).
Args:
bigram_freq (dict[str, dict[str, int]]): Dictionary of bigram frequencies
Returns:
dict[str, dict[str, float]]: Dictionary where first key is w1, second key is w2,
value is P(w2|w1)
"""
# Calculate marginal frequencies for first words
first_word_freq = defaultdict(int)
for w1, w1_dict in bigram_freq.items():
for w2, freq in w1_dict.items():
first_word_freq[w1] += freq
# Calculate conditional probabilities
conditional_probs = defaultdict(dict)
for w1, w1_dict in bigram_freq.items():
for w2, freq in w1_dict.items():
conditional_probs[w1][w2] = freq / first_word_freq[w1] if first_word_freq[w1] > 0 else 0.0
return dict(conditional_probs)
実際に確立を求めてみると以下のような感じになります。
conditional_probs = get_conditional_probabilities(bigram_freq)
print(list(conditional_probs.items())[:5]) # Print first 5 entries for brevity
[('<SEP>', {'ニ': 0.034690682539512914, 'ナ': 0.020493817925344902, ...
無意味文字列生成
bigramの確率分布を用いて無意味文字列を生成してみます。
自然長で生成
せっかくbigramなので、<SEP>が出てきた時点で生成停止というポリシーで生成してみます。
def random_mora_by_bigram(conditional_probs: dict[str, dict[str, float]], max_length: int = 100) -> str:
"""
Generate a random mora sequence using bigram conditional probabilities.
Starts with <BOS> token and continues until <EOS> token or max_length is reached.
Args:
conditional_probs: Dictionary of conditional probabilities P(w2|w1)
max_length: Maximum length of generated sequence (excluding special tokens)
Returns:
str: Generated mora sequence (without special tokens)
Raises:
ValueError: If max_length is not positive or conditional_probs is empty
"""
if max_length <= 0:
raise ValueError(f"Expected positive integer, got {max_length}")
if not conditional_probs:
raise ValueError("Conditional probabilities dictionary is empty")
# Start with SEP token
current_mora = "<SEP>"
sequence = []
for _ in range(max_length):
# Get possible next moras for current mora
if current_mora not in conditional_probs:
# If current mora not found, break (shouldn't happen with proper training data)
print(f"Warning: '{current_mora}' not found in conditional probabilities")
break
next_mora_probs = conditional_probs[current_mora]
# Convert to lists for numpy choice
next_moras = list(next_mora_probs.keys())
probs = list(next_mora_probs.values())
# Choose next mora based on probabilities
next_mora = np.random.choice(next_moras, p=probs)
# Check if we reached end of sequence
if next_mora == "<SEP>":
break
# Add to sequence and update current mora
sequence.append(next_mora)
current_mora = next_mora
return "".join(sequence)
実行してみます。
unigramのときよりもより日本語らしい感じがします。
for _ in range(5):
print(random_mora_by_bigram(conditional_probs))
カイチダレルエイタノヨルフ
ジドーシト
イテイマションゲントデタリワカフェイカ
コロージトイダメンハハンハウエックサンプノワ
ホー
指定文字数
<SEP>の登場に任せていると、短すぎたり、長すぎたりするので、指定文字数の生成も試してみます。
処理としては、<SEP>が出てこないように次のモウラを生成することを続けるだけです。
def random_mora_by_bigram_with_length(conditional_probs: dict[str, dict[str, float]], target_length: int) -> str:
"""
Generate a random mora sequence with approximately target length using bigram probabilities.
If <EOS> is encountered before target length, restarts generation.
Args:
conditional_probs: Dictionary of conditional probabilities P(w2|w1)
target_length: Target length of generated sequence
Returns:
str: Generated mora sequence of approximately target length
"""
if target_length <= 0:
raise ValueError(f"Expected positive integer, got {target_length}")
# Start with SEP token
current_mora = "<SEP>"
sequence = []
for _ in range(target_length):
# Get possible next moras for current mora
if current_mora not in conditional_probs:
# If current mora not found, break (shouldn't happen with proper training data)
print(f"Warning: '{current_mora}' not found in conditional probabilities")
break
next_mora_probs = conditional_probs[current_mora]
# Convert to lists for numpy choice, <SEP>は除外
next_moras = [m for m in next_mora_probs.keys() if m != "<SEP>"]
probs = [next_mora_probs[m] for m in next_moras]
# 正規化
if probs:
probs = np.array(probs, dtype=np.float64)
probs /= probs.sum()
else:
# もし<SEP>しかなければbreak
break
# Choose next mora based on probabilities
next_mora = np.random.choice(next_moras, p=probs)
# Add to sequence and update current mora
sequence.append(next_mora)
current_mora = next_mora
return "".join(sequence)
実行してみます。自然長の場合と同じく、つながりは自然な気がします。
for _ in range(5):
print(random_mora_by_bigram_with_length(conditional_probs, target_length=32))
ハチパックオイウカントナジュートキノハーナイコンサンデトチエスエス
サイデスケオーサーキニワカンリネスエイバンショーモシレダブルニジシ
カラタイシーフカイオチェリニタリニジューモハマデテワキノーバヤメンデ
サレレワヒャクニセッドワスエントオートアマシモアリモトメノモックト
イーネッチティクアンカイウデスゴオクシテキリシラコショーカノマッパジュ
おわりに
無意味カナ文字列に日本語っぽさを加えるために、コーパスから取得したモウラの出現頻度に従わせることを試してみました。
unigramだと少し微妙ですが、bigramにするとだいぶいい感じになったような気がします。