LLM推論ベースのツリーインデックスRAG「PageIndex」でPDF/Markdownから階層構造を抽出する

初めに

github.com

PageIndexは、ベクトルDBやチャンキングを使わず、LLMの推論によって階層ツリーインデックスを構築するRAGシステムです。PDF/Markdownから目次のようなツリー構造を自動生成し、ツリー検索で関連ページを特定します。

従来のRAGはベクトル類似度検索に依存しますが、「類似度≠関連度」という問題があります。PageIndexはLLMの推論能力を活用し、人間の専門家がドキュメントをナビゲートするように関連箇所を特定します。

PageIndexの処理は大きく2ステップに分かれます。

  1. ツリーインデックス構築 — ドキュメントから階層ツリーを生成する
  2. ツリー検索 — 構築済みツリーをたどって関連ページを特定する

このリポジトリは①のツリーインデックス構築を担当します。本記事ではPDFとMarkdownそれぞれの構築方法を紹介します。

開発環境

環境構築

リポジトリをクローンします。

git clone https://github.com/VectifyAI/PageIndex.git
cd PageIndex

依存関係をインストールします。

uv add -r requirements.txt

.envファイルを作成してOpenAI APIキーを設定します。

CHATGPT_API_KEY=your_openai_key_here

PDFの処理

PDF処理では、LLMを複数段階で活用してツリーを構築します。

  1. PDFからテキストを抽出
  2. LLMで目次(TOC)ページを検出
  3. TOCJSON構造に変換(LLM)
  4. 各セクションにページ番号を割当(LLM)
  5. 割当結果を検証・修正(LLM)
  6. 各ノードのサマリを非同期並列生成(LLM)

各ノードは以下のような構造を持ちます。

{
  "title": "INTRODUCTION",
  "node_id": "0000",
  "start_index": 1,
  "end_index": 2,
  "summary": "This section introduces...",
  "nodes": []
}

start_index/end_indexがページ範囲、nodesが子ノードの配列です。

サンプルPDF(earthmover.pdf)でツリー構造を生成します。

python3 run_pageindex.py --pdf_path tests/pdfs/earthmover.pdf

results/earthmover_structure.jsonが生成されます。可視化スクリプトで確認すると以下のようなツリーが得られます。

[doc] earthmover.pdf  (19 nodes)

[0000] INTRODUCTION (p.1-2) [S]
[0001] PRELIMINARIES (p.2) [S]
├── [0002] Computing the EMD (p.3) [S]
└── [0003] Filter-and-Refinement Framework (p.3-4) [S]
[0004] SCALING UP SSP (p.4-5) [S]
[0005] BOOSTING THE REFINEMENT PHASE (p.5) [S]
├── [0006] Analysis of EMD Calculation (p.5-8) [S]
├── [0007] Progressive Bounding (p.8-6) [S]
├── [0008] Sensitivity to Refinement Order (p.6-9) [S]
├── [0009] Dynamic Refinement Ordering (p.9-7) [S]
└── [0010] Running Upper Bound (p.7-8) [S]
[0011] EXPERIMENTAL EVALUATION (p.8) [S]
├── [0012] Performance Improvement (p.8-10) [S]
├── [0013] Scalability Experiments (p.10-11) [S]
└── [0014] Parameter Tuning in DRO (p.11-12) [S]
[0015] RELATED WORK (p.12) [S]
[0016] CONCLUSION (p.12) [S]
[0017] ACKNOWLEDGMENT (p.12) [S]
[0018] REFERENCES (p.12) [S]

12ページのPDFから19ノードのツリーが構築され、各ノードにページ範囲とサマリ([S])が付与されています。

Markdownの処理

Markdownファイルでもツリー構造を生成できます。PDFとは異なり、ヘッダ階層(#, ##, ###...)から直接ツリーを構築するため、TOC検出やページ割当のステップは不要です。LLMはサマリ生成時のみ使用されます。

python3 run_pageindex.py --md_path tests/md/japanese_sample.md

Markdownの場合はヘッダ階層(#, ##, ###...)からツリーを構築します。

[doc] japanese_sample  (25 nodes)

[0000] Webアプリケーション開発ガイド (L1) [S]
├── [0001] 第1章 フロントエンド開発 (L5) [S]
│   ├── [0002] 1.1 HTML/CSSの基礎 (L9) [S]
│   └── [0003] 1.2 JavaScriptフレームワーク (L22) [S]
│       ├── [0004] 1.2.1 React (L26) [S]
│       ├── [0005] 1.2.2 Vue.js (L38) [S]
│       └── [0006] 1.2.3 パフォーマンス最適化 (L42) [S]
├── [0007] 第2章 バックエンド開発 (L51) [S]
│   ├── [0008] 2.1 API設計 (L55) [S]
│   │   ├── [0009] 2.1.1 RESTful API (L59) [S]
│   │   └── [0010] 2.1.2 GraphQL (L69) [S]
│   ├── [0011] 2.2 データベース設計 (L85) [S]
│   │   ├── [0012] 2.2.1 リレーショナルデータベース (L89) [S]
│   │   └── [0013] 2.2.2 NoSQLデータベース (L93) [S]
│   └── [0014] 2.3 認証・認可 (L97) [S]
├── [0015] 第3章 インフラストラクチャ (L106) [S]
│   ├── [0016] 3.1 コンテナ技術 (L110) [S]
│   │   ├── [0017] 3.1.1 Docker (L114) [S]
│   │   └── [0018] 3.1.2 Kubernetes (L128) [S]
│   ├── [0019] 3.2 CI/CDパイプライン (L132) [S]
│   └── [0020] 3.3 監視とオブザーバビリティ (L151) [S]
└── [0021] 第4章 開発プラクティス (L160) [S]
    ├── [0022] 4.1 テスト戦略 (L164) [S]
    ├── [0023] 4.2 コードレビュー (L174) [S]
    └── [0024] 4.3 アジャイル開発 (L185) [S]

Markdownのヘッダ構造がそのままツリーに反映され、25ノードが生成されています。PDFと異なりページ番号ではなく行番号(L)で位置が示されます。

主なオプション

run_pageindex.pyにはいくつかのオプションがあります。

  • --if-add-node-summary yes/no — 各ノードにLLM生成のサマリを付与(デフォルト: yes)
  • --if-add-doc-description yes/no — ドキュメント全体の説明文を生成(デフォルト: no)
  • --if-thinning yes/noMarkdown用。トークン数の少ないノードを親に統合してツリーを簡略化(デフォルト: no)
  • --model — 使用するOpenAIモデル(デフォルト: gpt-4o-2024-11-20)
  • --max-pages-per-node — PDF用。1ノードあたりの最大ページ数(デフォルト: 10)
  • --max-tokens-per-node — PDF用。1ノードあたりの最大トークン数(デフォルト: 20000)

MegaTTS3の英語推論環境をWindowsで構築してGradioから音声合成をする

初めに

bytedanceから英語・中国語に対応したTTSモデルが公開されたので動かしてみます

github.com

開発環境

重要な制約

  • WeTextProcessingpyniniWindows でのビルドが難しく、uv add だけでは導入できません。
  • そのため、この構成では 英語のみ で推論します。
  • 中国語を入力すると tts/infer_cli.py が例外を出します。

環境構築

uvを使って以下の手順で環境構築を進めていきます

uv init --bare --name megatts3 --python 3.10

Windows 用の依存リスト ( requirements.windows.txt ) を用意します

torch==2.6.0
torchaudio==2.6.0
numpy<2
attrdict==2.0.1
librosa==0.10.2.post1
langdetect==1.0.9
pydub==0.25.1
pyloudnorm==0.1.1
modelscope==1.22.2
transformers>=4.41.2,<=4.49.0,!=4.46.*,!=4.47.*,!=4.48.*;python_version<'3.10'
transformers>=4.41.2,<=4.49.0,!=4.46.*,!=4.47.*,!=4.48.0;python_version>='3.10'
x-transformers==1.44.4
torchdiffeq==0.2.5
openai-whisper==20240930
httpx==0.28.1
gradio==5.23.1

依存のインストールします

uv add -r requirements.windows.txt -p 3.10

この実行で以下が作成/更新されます

  • .venv
  • pyproject.toml
  • uv.lock

実行

CLIで実行する場合は以下で実行します

uv run python tts/infer_cli.py --input_wav "assets/English_prompt.wav" --input_text "Your English text here." --output_dir ./gen

Gradio UIは以下で起動します.

uv run python tts/gradio_api.py

音声とテキストから発話の時間境界付きの Praat TextGrid を生成する「Wav2TextGrid」を英語音声で試してみる

初めに

まだ試験的ですが以下のライブラリが出てきていたので触ってみます

github.com

対応言語は英語のみのため、日本語を使いたい場合は自前で学習する必要があります。

モデル/アーキテクチャ

  • Wav2Vec2 によるフレームレベルの音素予測
  • 強制アラインについては、予測された音素 posterior を、Viterbi decodingで転写音素列に整列して時間境界を得る

開発環境

環境構築

uvが入っている前提で以下を実行します

uv sync

実行

サンプルデータを使って実行してみます

uv run python scripts\\run_inference_workflow.py --examples-dir examples --output-dir outputs

出力結果 (0.wav)は以下のようになります

File type = "ooTextFile"
Object class = "TextGrid"

xmin = 0 
xmax = 2.37 
tiers? <exists> 
size = 2 
item []: 
    item [1]:
        class = "IntervalTier" 
        name = "phones" 
        xmin = 0 
        xmax = 2.37 
        intervals: size = 8 
        intervals [1]:
            xmin = 0 
            xmax = 0.42 
            text = "[SIL]" 
        intervals [2]:
            xmin = 0.42 
            xmax = 0.57 
            text = "B" 
        intervals [3]:
            xmin = 0.57 
            xmax = 1.37 
            text = "ER" 
        intervals [4]:
            xmin = 1.37 
            xmax = 1.43 
            text = "D" 
        intervals [5]:
            xmin = 1.43 
            xmax = 1.51 
            text = "HH" 
        intervals [6]:
            xmin = 1.51 
            xmax = 1.73 
            text = "AW" 
        intervals [7]:
            xmin = 1.73 
            xmax = 2.05 
            text = "S" 
        intervals [8]:
            xmin = 2.05 
            xmax = 2.37 
            text = "[SIL]" 
    item [2]:
        class = "IntervalTier" 
        name = "words" 
        xmin = 0 
        xmax = 2.37 
        intervals: size = 3 
        intervals [1]:
            xmin = 0 
            xmax = 0.42 
            text = "[SIL]" 
        intervals [2]:
            xmin = 0.42 
            xmax = 2.05 
            text = "birdhouse" 
        intervals [3]:
            xmin = 2.05 
            xmax = 2.37 
            text = "[SIL]" 

音楽生成モデルのheartlibで英語・日本語の歌詞付き音楽を生成をする

初めに

以下でOSSで音楽生成モデルが出たので、さわってみます

github.com

開発環境

  • Python 3.10
  • CUDA 12.x対応GPU
  • UV (Pythonパッケージマネージャー)

環境構築

以下の設定をpyproject.tomlに追加(CUDA 12.4 + Flash Attention対応):

  [[tool.uv.index]]
  name = "pytorch-cu124"
  url = "https://download.pytorch.org/whl/cu124"
  explicit = true

  [tool.uv.sources]
  torch = { index = "pytorch-cu124" }
  torchaudio = { index = "pytorch-cu124" }
  torchvision = { index = "pytorch-cu124" }
  flash-attn = { url = "https://huggingface.co/lldacing/flash-attention-windows-wheel/resolve/main/flash_attn-2.7.4+cu126torch2.6.0cxx11abiFALSE-cp310-cp310-win_amd64.whl" }

依存関係のインストールします

  uv sync

モデルのダウンロードします

  # Python APIを使用(Windows環境での文字化け回避)
  from huggingface_hub import snapshot_download

  snapshot_download("HeartMuLa/HeartMuLaGen", local_dir="./ckpt")
  snapshot_download("HeartMuLa/HeartMuLa-oss-3B", local_dir="./ckpt/HeartMuLa-oss-3B")
  snapshot_download("HeartMuLa/HeartCodec-oss", local_dir="./ckpt/HeartCodec-oss")

実行

以下でGradio UIの起動します

  uv run python app.py

起動をすると以下のようになります

日本語の歌詞付きの音楽を生成すると以下になります

CosyVoiceのonnxモデルをUnityで動かして音声合成をするときのopset versionについて

初めに

昨日に CosyVoice3をonnxにして pytorchに依存せずに動かしてみました

ayousanz.hatenadiary.jp

今回はここで変換をしたonnxをUnityで動かして、UnityだけでCosyVoiceのモデルから推論をしてみたいと思います

以下でRepositoryを公開しています

github.com

記事では動かす際にopset問題とGPU対応について詳しく書いていきます。ほかの実装等はリポジトリをご確認ください。

開発環境

項目 バージョン
Unity 6000.0.58f2
Unity AI Interface 2.4.1
OS Windows 11
GPU RTX 4070 Ti SUPER

ONNX Opsetバージョン問題

Unity AI Interfaceの公式ドキュメントでは、以下のように記載されています。

サポートされるONNX opset: 7〜15

opset 15より高いモデルをインポートすると、結果が予測不能になる可能性があります。

uCosyVoiceでは14ファイルのONNXモデルを使用しています。各モデルのopsetバージョンと動作結果は以下の通りです。

Opset モデル 動作
14 campplus.onnx ✅ 正常
15 llm_backbone_initial_fp16.onnx ✅ 正常
15 llm_backbone_decode_fp16.onnx ✅ 正常
15 llm_decoder_fp16.onnx ✅ 正常
15 llm_speech_embedding_fp16.onnx ✅ 正常
15 flow_token_embedding_fp16.onnx ✅ 正常
15 flow_pre_lookahead_fp16.onnx ✅ 正常
15 flow_speaker_projection_fp16.onnx ✅ 正常
15 hift_f0_predictor_fp32.onnx ✅ 正常
15 hift_decoder_fp32.onnx ✅ 正常
16 speech_tokenizer_v3.onnx ✅ 正常
17 text_embedding_fp32.onnx ✅ 正常
17 hift_source_generator_fp32.onnx ✅ 正常
18 flow.decoder.estimator.fp16.onnx ✅ 正常

結果: 全120テストが合格し、opset 16〜18のモデルも問題なく動作しました。

そのためopsetバージョンは「そのバージョンで定義された演算子(オペレータ)が使える」ということがわかります

重要なのは以下の2点です。

  1. モデルが実際に使用している演算子がUnity AI Interfaceでサポートされているか
  2. opsetバージョンの数字そのものではない

LLMベースTTS「CosyVoice3」を完全ONNX化してPyTorchなしで推論する

はじめに

CosyVoice3は、Alibaba FunAudioLLMが開発したLLMベースの音声合成(TTS)システムです。9言語以上に対応し、ゼロショット音声クローニングが可能な最新のTTSモデルです。

今回、このCosyVoice3を完全にONNX化し、PyTorchなしで推論できるようにしました。Unity Sentisでの利用やエッジデバイスへの展開を見据えた取り組みです。

本記事では、ONNXエクスポートから推論実装までの技術的な詳細を解説します。

成果物 (ONNXモデル/推論スクリプト)

huggingface.co

CosyVoice3のアーキテクチャ

CosyVoice3は4段階のパイプラインで構成されています。

テキスト入力
    ↓
[1. Tokenizer] テキスト → トークンID
    ↓
[2. LLM] Qwen2ベース → 音声トークン生成(自己回帰)
    ↓
[3. Flow] DiT + Euler Solver → メルスペクトログラム
    ↓
[4. HiFT] F0予測 + Source生成 + Decoder → 24kHz音声波形

Zero-Shot音声クローニングの仕組み

Zero-Shotモードでは、プロンプト音声から話者特徴を抽出し、任意のテキストをその声で読み上げます。

プロンプト音声
    ├── [CAMPPlus] → 話者埋め込み(192次元)
    ├── [Speech Tokenizer] → 音声トークン(LLMコンテキスト用)
    └── [librosa] → メルスペクトログラム(Flow条件付け用)

開発環境

項目 バージョン
OS Windows 11
Python 3.10
PyTorch 2.5.1+cu124(エクスポート時のみ)
ONNX Runtime 1.18.0
NumPy 1.26.4

ONNXエクスポート

CosyVoice3を14個のONNXモデルに分割してエクスポートしました

  1. LLMのKVキャッシュ: 初回パスとデコードパスで入出力形状が異なる
  2. 精度要件: HiFTはFP32必須、LLM/FlowはFP16で動作可能
  3. Unity Sentis対応: 動的形状の制約を回避

生成されたONNXファイル

ファイル サイズ 精度 用途
text_embedding_fp32.onnx 544MB FP32 テキスト埋め込み(Qwen2)
llm_backbone_initial_fp16.onnx 717MB FP16 LLM初回パス
llm_backbone_decode_fp16.onnx 717MB FP16 LLMデコードステップ
llm_decoder_fp16.onnx 12MB FP16 Logits出力
llm_speech_embedding_fp16.onnx 12MB FP16 音声トークン埋め込み
flow_token_embedding_fp16.onnx 1MB FP16 Flowトークン埋め込み
flow_pre_lookahead_fp16.onnx 1MB FP16 Flow前処理
flow_speaker_projection_fp16.onnx 31KB FP16 話者投影
flow.decoder.estimator.fp16.onnx 664MB FP16 Flow DiT
hift_f0_predictor_fp32.onnx 13MB FP32 F0予測
hift_source_generator_fp32.onnx 259MB FP32 Source信号生成
hift_decoder_fp32.onnx 70MB FP32 HiFTデコーダ
campplus.onnx 28MB FP32 話者埋め込み
speech_tokenizer_v3.onnx 969MB FP32 音声トークナイザー

合計サイズは約3.8GBです。

LLMのKVキャッシュ分割

LLMの自己回帰推論では、KVキャッシュの効率的な管理が重要です。CosyVoice3のLLM(Qwen2ベース)は24層あり、各層にKey/Valueキャッシュが必要です。

初回パス(llm_backbone_initial): - 入力: 全コンテキスト(プロンプト + テキスト埋め込み) - 出力: hidden_states + 24層分のKVキャッシュ

デコードパス(llm_backbone_decode): - 入力: 1トークンの埋め込み + 前ステップのKVキャッシュ - 出力: hidden_states + 更新されたKVキャッシュ

# エクスポート時の入出力定義
dynamic_axes = {
    'input_embeds': {0: 'batch', 1: 'seq_len'},
    'attention_mask': {0: 'batch', 1: 'total_len'},
}
for i in range(24):
    dynamic_axes[f'past_key_{i}'] = {0: 'batch', 2: 'past_len'}
    dynamic_axes[f'past_value_{i}'] = {0: 'batch', 2: 'past_len'}

HiFTのFP32必須問題

当初、HiFTコンポーネントもFP16でエクスポートしましたが、生成音声にノイズが入る問題が発生しました。

調査の結果、HiFTのSTFT/ISTFT処理で数値精度が重要であることがわかりました。FP32に変更することで問題が解決しました。

# HiFTはFP32でエクスポート
torch.onnx.export(
    hift_decoder,
    dummy_input,
    "hift_decoder_fp32.onnx",
    # FP16変換なし
)

PyTorchフリー推論の実装

依存パッケージ

PyTorchなしで推論するため、以下の最小構成で環境を構築しました。

uv init cosyvoice-onnx --python 3.10
cd cosyvoice-onnx
uv add "onnxruntime==1.18.0" "numpy==1.26.4" "soundfile==0.12.1" \
       "librosa==0.10.2" "transformers==4.51.3" "scipy==1.13.1"

バージョン固定の理由: - onnxruntime==1.18.0: 1.19以降はFP16モデルで互換性問題あり - numpy==1.26.4: ONNX Runtime 1.18.0はNumPy 2.x非対応

NumPy/SciPyによるSTFT/ISTFT実装

HiFTの波形生成にはSTFT/ISTFTが必要ですが、PyTorchのtorch.stftを使えないため、NumPy/SciPyで実装しました。

CosyVoice3のHiFTは特殊なパラメータを使用しています。

パラメータ 備考
n_fft 16 非常に小さい値
hop_length 4
upsample_rates [8, 5, 3] 120倍アップサンプル
def stft_numpy(x, n_fft=16, hop_length=4, center=True):
    """NumPyによるSTFT実装"""
    if center:
        pad_amount = n_fft // 2
        x = np.pad(x, pad_amount, mode='reflect')

    # ハニング窓
    window = np.hanning(n_fft + 1)[:-1].astype(np.float32)

    # フレーム分割
    num_frames = (len(x) - n_fft) // hop_length + 1
    frames = np.lib.stride_tricks.as_strided(
        x,
        shape=(num_frames, n_fft),
        strides=(x.strides[0] * hop_length, x.strides[0])
    ).copy()

    # 窓関数適用してFFT
    windowed = frames * window
    return np.fft.rfft(windowed, axis=1).astype(np.complex64)

def istft_numpy(stft_matrix, hop_length=4, n_fft=16, length=None, center=True):
    """NumPyによるISTFT実装"""
    window = np.hanning(n_fft + 1)[:-1].astype(np.float32)

    # IFFT
    time_frames = np.fft.irfft(stft_matrix, n=n_fft, axis=1).real.astype(np.float32)

    # Overlap-Add
    num_frames = time_frames.shape[0]
    expected_length = n_fft + hop_length * (num_frames - 1)
    output = np.zeros(expected_length, dtype=np.float32)
    window_sum = np.zeros(expected_length, dtype=np.float32)

    for i in range(num_frames):
        start = i * hop_length
        output[start:start + n_fft] += time_frames[i] * window
        window_sum[start:start + n_fft] += window ** 2

    # 正規化
    nonzero = window_sum > 1e-8
    output[nonzero] /= window_sum[nonzero]

    # centerパディングの除去
    if center:
        pad = n_fft // 2
        output = output[pad:-pad]

    if length is not None:
        output = output[:length]

    return output

推論ループの実装

Zero-Shotモードの推論フローは以下の通りです。

class CosyVoiceONNXInference:
    def inference_zero_shot(self, text, prompt_text, prompt_wav):
        # 1. プロンプト音声の処理
        speech_feat = self.extract_mel(prompt_wav)  # メル抽出
        speaker_embedding = self.campplus(prompt_wav)  # 話者埋め込み
        prompt_speech_tokens = self.speech_tokenizer(prompt_wav)  # 音声トークン

        # 2. テキストトークン化
        prompt_ids = self.tokenizer.encode(prompt_text)
        tts_ids = self.tokenizer.encode(text)

        # 3. 埋め込み生成
        text_embeds = self.text_embedding(prompt_ids + tts_ids)
        speech_embeds = self.speech_embedding(prompt_speech_tokens)

        # 4. LLM推論(自己回帰)
        # 初回パス
        hidden, kv_cache = self.llm_initial(text_embeds, speech_embeds)

        # デコードループ
        speech_tokens = []
        for _ in range(max_length):
            logits = self.llm_decoder(hidden)
            token = sample_token(logits)
            if token == eos_token:
                break
            speech_tokens.append(token)

            token_embed = self.speech_embedding([token])
            hidden, kv_cache = self.llm_decode(token_embed, kv_cache)

        # 5. Flow推論(メル生成)
        mel = self.flow_inference(speech_tokens, speaker_embedding, speech_feat)

        # 6. HiFT推論(波形生成)
        audio = self.hift_inference(mel)

        return audio

発見した問題と解決策

言語タグが発音される問題

当初、CosyVoice3の多言語対応のため <|en|><|ja|> などの言語タグを使用していました。しかし、生成音声でこれらのタグが文字通り発音されてしまう問題が発生しました。

原因: 言語タグはQwen2トークナイザーの特殊トークンではなく、通常のテキストとしてトークン化されていました。

# 言語タグのトークン化結果
tokenizer.encode("<|en|>")
# → [27, 91, 268, 91, 29]  # '<', '|', 'en', '|', '>' の5トークン

解決策: 言語タグを使用せず、CosyVoice3の自動言語検出に任せることで解決しました。

# NG: 言語タグあり
text = "<|en|>Hello, this is a test."

# OK: 言語タグなし(自動検出)
text = "Hello, this is a test."

ONNX Runtimeのバージョン問題

ONNX Runtime 1.19以降でFP16モデルを読み込むと以下のエラーが発生しました。

RuntimeException: Attempting to get index by a name which does not exist

これはONNX Runtime 1.19以降のFP16処理の変更に起因する問題です。ONNX Runtime 1.18.0を使用することで回避しました。

使い方

環境構築

実際にexportしたonnxは以下のように動かすことができます。まずはuvで環境を作ります

uv init cosyvoice-onnx --python 3.10
cd cosyvoice-onnx

uv add "onnxruntime==1.18.0" "numpy==1.26.4" "soundfile==0.12.1" \
       "librosa==0.10.2" "transformers==4.51.3" "scipy==1.13.1" \
       "huggingface_hub>=0.30.0"

次にモデル等をダウンロードします

# ONNXモデル + 推論スクリプト + サンプル音声
uv run python -c "
from huggingface_hub import snapshot_download
snapshot_download('ayousanz/cosy-voice3-onnx',
                  local_dir='pretrained_models/Fun-CosyVoice3-0.5B/onnx')
"

# トークナイザー(元モデルから)
uv run python -c "
from modelscope import snapshot_download
snapshot_download('FunAudioLLM/Fun-CosyVoice3-0.5B-2512',
                  local_dir='pretrained_models/Fun-CosyVoice3-0.5B',
                  allow_patterns=['CosyVoice-BlankEN/*.json', 'CosyVoice-BlankEN/*.txt'])
"

推論実行

uv run python pretrained_models/Fun-CosyVoice3-0.5B/onnx/scripts/onnx_inference_pure.py \
    --text "Hello, this is a test of ONNX inference." \
    --prompt_wav pretrained_models/Fun-CosyVoice3-0.5B/onnx/prompts/en_female_nova_greeting.wav \
    --prompt_text "Hello, my name is Sarah." \
    --output output.wav

100言語での音韻変換に対応しているCharsiuG2Pで日本語精度を・英語精度を試す

初めに

G2Pライブラリの一つであるニューラルG2Pモデル CharsiuG2P で日本語および英語の精度を測ってみます

特徴

項目 内容
ライブラリ CharsiuG2P
モデル charsiu/g2p_multilingual_byT5_small_100
ライセンス MIT
対応言語 100言語(日本語含む)
アーキテクチャ byT5 (Byte-level T5)

提供モデル

モデル PER WER
tiny 8-layer 0.107 0.314
tiny 12-layer 0.098 0.287
tiny 16-layer 0.096 0.281
small (今回検証) 0.089 0.261

開発環境

環境構築

以下でuvで環境を作っていきます

uv init

次に 必要なライブラリを入れます

uv add transformers torch --index-url https://download.pytorch.org/whl/cu124

精度比較

次に英語と日本語の両方の測度と精度を測るためのスクリプトを作成します

"""CharsiuG2P G2P検証スクリプト(日本語・英語)"""

import sys
import io

# Windows環境でのUTF-8出力対応
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')

from transformers import T5ForConditionalGeneration, AutoTokenizer
import torch
import time


def test_language(model, tokenizer, lang_code, lang_name, test_words, device):
    """指定言語でG2P変換テストを実行"""
    print(f"\n[{lang_name}] G2P変換テスト")
    print("-" * 60)

    total_time = 0
    results = []

    for word, expected in test_words:
        input_text = f"<{lang_code}>: {word}"
        inputs = tokenizer(input_text, return_tensors="pt").to(device)

        # ウォームアップ(最初の1回はスキップ)
        if len(results) == 0 and device.type == "cuda":
            _ = model.generate(**inputs, max_length=50)
            torch.cuda.synchronize()

        start = time.time()
        outputs = model.generate(**inputs, max_length=50)
        if device.type == "cuda":
            torch.cuda.synchronize()
        inference_time = time.time() - start
        total_time += inference_time

        phonemes = tokenizer.decode(outputs[0], skip_special_tokens=True)
        results.append((word, phonemes, expected, inference_time))

        print(f"入力: {word}")
        print(f"  出力: {phonemes}")
        print(f"  期待: {expected}")
        print(f"  時間: {inference_time*1000:.1f}ms")
        print()

    avg_time = (total_time / len(test_words)) * 1000
    throughput = len(test_words) / total_time

    print("-" * 60)
    print(f"平均推論時間: {avg_time:.1f}ms/語")
    print(f"スループット: {throughput:.1f}語/秒")

    return results, avg_time, throughput


def run_test(device_name="cpu"):
    """指定デバイスでテストを実行"""
    device = torch.device(device_name)

    print("=" * 60)
    print(f"CharsiuG2P G2P検証(日本語・英語)- {device_name.upper()}")
    print("=" * 60)

    # モデルとトークナイザーのロード
    print("\n[1] モデルをロード中...")
    start = time.time()

    model_name = "charsiu/g2p_multilingual_byT5_small_100"
    tokenizer = AutoTokenizer.from_pretrained("google/byt5-small")
    model = T5ForConditionalGeneration.from_pretrained(model_name).to(device)

    load_time = time.time() - start
    print(f"    ロード完了: {load_time:.2f}秒")
    print(f"    デバイス: {device}")

    # モデル情報
    param_count = sum(p.numel() for p in model.parameters())
    print(f"    パラメータ数: {param_count:,}")

    # ========== 英語テスト ==========
    english_words = [
        ("hello", "hɛˈloʊ"),
        ("world", "wɜːld"),
        ("computer", "kəmˈpjuːtər"),
        ("language", "ˈlæŋɡwɪdʒ"),
        ("phoneme", "ˈfoʊniːm"),
        ("Tokyo", "ˈtoʊkioʊ"),
        ("Microsoft", "ˈmaɪkroʊsɒft"),
        ("Google", "ˈɡuːɡəl"),
        ("psychology", "saɪˈkɒlədʒi"),
        ("knight", "naɪt"),
        ("through", "θruː"),
        ("enough", "ɪˈnʌf"),
    ]

    en_results, en_avg_time, en_throughput = test_language(
        model, tokenizer, "eng-us", "英語 (eng-us)", english_words, device
    )

    # ========== 日本語テスト ==========
    japanese_words = [
        ("東京", "toːkjoː"),
        ("大阪", "oːsaka"),
        ("京都", "kjoːto"),
        ("日本", "nihoɴ / nippoɴ"),
        ("こんにちは", "konnitɕiwa"),
        ("ありがとう", "ariɡatoː"),
        ("コンピュータ", "kompjuːta"),
        ("インターネット", "intaːnetto"),
        ("人工知能", "dʑinkoːtɕinoː"),
        ("機械学習", "kikaiɡakuɕɯː"),
        ("渋谷", "ɕibuja"),
        ("秋葉原", "akihabara"),
    ]

    ja_results, ja_avg_time, ja_throughput = test_language(
        model, tokenizer, "jpn", "日本語 (jpn)", japanese_words, device
    )

    return {
        "device": device_name,
        "en_avg_time": en_avg_time,
        "en_throughput": en_throughput,
        "ja_avg_time": ja_avg_time,
        "ja_throughput": ja_throughput,
        "en_results": en_results,
        "ja_results": ja_results,
        "param_count": param_count,
    }


def main():
    # GPU情報表示
    print("=" * 60)
    print("システム情報")
    print("=" * 60)
    print(f"PyTorch: {torch.__version__}")
    print(f"CUDA available: {torch.cuda.is_available()}")
    if torch.cuda.is_available():
        print(f"CUDA version: {torch.version.cuda}")
        print(f"GPU: {torch.cuda.get_device_name(0)}")
    print()

    results = {}

    # CPUテスト
    results["cpu"] = run_test("cpu")

    # GPUテスト(利用可能な場合)
    if torch.cuda.is_available():
        print("\n\n")
        results["cuda"] = run_test("cuda")

    # ========== サマリー ==========
    print("\n\n" + "=" * 60)
    print("検証サマリー")
    print("=" * 60)

    for device, r in results.items():
        print(f"\n【{device.upper()}】")
        print(f"  英語:")
        print(f"    平均推論時間: {r['en_avg_time']:.1f}ms/語")
        print(f"    スループット: {r['en_throughput']:.1f}語/秒")
        print(f"  日本語:")
        print(f"    平均推論時間: {r['ja_avg_time']:.1f}ms/語")
        print(f"    スループット: {r['ja_throughput']:.1f}語/秒")

    # 速度比較
    if "cuda" in results:
        print("\n【速度比較 (CPU vs GPU)】")
        cpu_en = results["cpu"]["en_avg_time"]
        gpu_en = results["cuda"]["en_avg_time"]
        cpu_ja = results["cpu"]["ja_avg_time"]
        gpu_ja = results["cuda"]["ja_avg_time"]
        print(f"  英語: {cpu_en:.1f}ms → {gpu_en:.1f}ms ({cpu_en/gpu_en:.1f}x 高速化)")
        print(f"  日本語: {cpu_ja:.1f}ms → {gpu_ja:.1f}ms ({cpu_ja/gpu_ja:.1f}x 高速化)")

    print("\n" + "=" * 60)
    print("検証完了")
    print("=" * 60)

    # 結果をファイルに保存
    with open("results.txt", "w", encoding="utf-8") as f:
        f.write("CharsiuG2P G2P検証結果(日本語・英語、CPU/GPU比較)\n")
        f.write("=" * 60 + "\n\n")

        f.write(f"PyTorch: {torch.__version__}\n")
        if torch.cuda.is_available():
            f.write(f"GPU: {torch.cuda.get_device_name(0)}\n")
        f.write(f"パラメータ数: {results['cpu']['param_count']:,}\n\n")

        for device, r in results.items():
            f.write(f"{'=' * 60}\n")
            f.write(f"【{device.upper()}】\n")
            f.write(f"{'=' * 60}\n\n")

            f.write("英語 (eng-us) 変換結果:\n")
            f.write("-" * 60 + "\n")
            for word, phonemes, expected, t in r["en_results"]:
                f.write(f"入力: {word}\n")
                f.write(f"  出力: {phonemes}\n")
                f.write(f"  期待: {expected}\n")
                f.write(f"  時間: {t*1000:.1f}ms\n\n")
            f.write(f"平均推論時間: {r['en_avg_time']:.1f}ms/語\n")
            f.write(f"スループット: {r['en_throughput']:.1f}語/秒\n\n")

            f.write("日本語 (jpn) 変換結果:\n")
            f.write("-" * 60 + "\n")
            for word, phonemes, expected, t in r["ja_results"]:
                f.write(f"入力: {word}\n")
                f.write(f"  出力: {phonemes}\n")
                f.write(f"  期待: {expected}\n")
                f.write(f"  時間: {t*1000:.1f}ms\n\n")
            f.write(f"平均推論時間: {r['ja_avg_time']:.1f}ms/語\n")
            f.write(f"スループット: {r['ja_throughput']:.1f}語/秒\n\n")

        if "cuda" in results:
            f.write("=" * 60 + "\n")
            f.write("速度比較 (CPU vs GPU)\n")
            f.write("=" * 60 + "\n")
            cpu_en = results["cpu"]["en_avg_time"]
            gpu_en = results["cuda"]["en_avg_time"]
            cpu_ja = results["cpu"]["ja_avg_time"]
            gpu_ja = results["cuda"]["ja_avg_time"]
            f.write(f"英語: {cpu_en:.1f}ms → {gpu_en:.1f}ms ({cpu_en/gpu_en:.1f}x 高速化)\n")
            f.write(f"日本語: {cpu_ja:.1f}ms → {gpu_ja:.1f}ms ({cpu_ja/gpu_ja:.1f}x 高速化)\n")

    print("\n結果をresults.txtに保存しました")


if __name__ == "__main__":
    main()

速度測定結果

速度はCPU、GPUでそれぞれ以下のようになりました

項目 英語 日本語
推論速度 958 ms/語 857 ms/語
スループット 1.0 語/秒 1.2 語/秒

GPU (RTX 4070 Ti SUPER)

項目 英語 日本語
推論速度 175 ms/語 167 ms/語
スループット 5.7 語/秒 6.0 語/秒

精度比較

精度評価(英語)

入力 出力 期待値 評価
hello ˈhɛɫoʊ hɛˈloʊ ✅ ほぼ正確
world ˈwɝɫd wɜːld ✅ ほぼ正確
computer kəmˈpjutɝ kəmˈpjuːtər ✅ ほぼ正確
language ˈɫæŋɡwɪdʒ ˈlæŋɡwɪdʒ
phoneme ˈfoʊnməpə ˈfoʊniːm ❌ 余計な文字
Tokyo ˈtoʊkiˌoʊ ˈtoʊkioʊ
Microsoft ˈmaɪkɹoʊˌsɔft ˈmaɪkroʊsɒft ✅ ほぼ正確
Google ˈɡuɡəɫ ˈɡuːɡəl ✅ ほぼ正確
psychology saɪˈkɑɫədʒi saɪˈkɒlədʒi ✅ ほぼ正確
knight ˈnaɪt naɪt
through ˈθɹaʊ θruː ❌ 誤変換
enough ɪˈnəf ɪˈnʌf ✅ ほぼ正確

精度評価(日本語)

入力 出力 期待値 評価
東京 toɯkjoɯ toːkjoː △ 長音がɯに
大阪 oosakazɯki oːsaka ❌ 余計な文字
京都 kjoɯtoɯ kjoːto △ 長音がɯに
日本 nihoɴ nihoɴ
こんにちは koɴnitɕiha konnitɕiwa △ ha/waの違い
ありがとう aɾigatoɯ ariɡatoː △ 長音がɯに
コンピュータ koɴpjɯːta kompjuːta ✅ ほぼ正確
インターネット iɴtaːnetːo intaːnetto ✅ ほぼ正確
人工知能 dʑiɴkoɯtɕinoɯ dʑinkoːtɕinoː △ 長音がɯに
機械学習 kikaigakɯɕɯɯ kikaiɡakuɕɯː
渋谷 ɕibɯtani ɕibuja ❌ 誤読
秋葉原 akibahaɾa akihabara ✅ ほぼ正確