📖

特化型llm(Doujinshi-1.8b)の開発報告書③:特定のドメインに特化したsft用データセット(コーパス)の作成

2025/03/19に公開

はじめに

沼津高専のpuwaerです。この度、R18に特化した大規模言語モデル(LLM)、Doujinshi-1.8bを開発しました。
この記事では、特定のドメインに特化したsft用データセット(コーパス)の作成方法をプログラムを交えて解説します。

r18に特化した事前学習用データセットを使用し、llm-jp/llm-jp-3-1.8bを学習させ、ネットで公開されている次のようなsft用のデータセット(AutoMultiTurnByCalm3-22B,ramdom-to-fixed-multiturn-Calm3,magpie-sft-v1.0 )をファインチューニングしました。
しかし、その結果として 事前学習で獲得した r18 の知識がほとんど失われ、SFT 用データセットの内容に大きく引きずられる という問題が発生しました。これは、モデルのサイズが 1.8B と比較的小さく、直前の学習データの影響を強く受けやすいためと考えられます。
gpuの計算リソースに限りがあるため、1.8B より大きなモデルの学習は個人開発では現実的ではありません。そこで、特定のドメインに特化した SFT 用データセット(コーパス)を独自に作成する というアプローチを取ることにしました。
人力でのデータ作成は非現実的なため、Web からスクレイピングしたテキストデータと LLM を活用し、効率的に特定のドメインに特化した SFT 用データセット(コーパス)を構築 しました。

本記事では、合成データの具体的な手法について詳しく解説します。

下の流れでする合成データの具体的な手法について解説します。
1.使用機器
2.環境構築
3.各プログラムの役割
4.プログラムの詳細

本記事で利用するプログラムは以下のGitHubリポジトリで管理しています。
https://github.com/puwaer/sft_generateed_data

このプログラムで作成したsft用のデータセット

1.使用機器

以下のようなpcを使い開発しました。
高専4年生でまだ研究室配属されていないため教授に頼みpcを貸してもらいました。
また、個人のpcはdeepspeedを用いたllmの継続事前学習の検証用にramが増設されています。

機器 CPU RAM GPU VRAM
研究室PC Intel i7-12700 64GB NVIDIA RTX A6000 48GB
個人PC Ryzen 7 5700X 96GB NVIDIA GeForce RTX 3060 12GB

2.環境構築

int4bitの量子化したモデルを使用して計算量を抑えて合成データを作成するため、llama.cpp をgpu環境で動作せるため、Docker環境で環境構築します。

前提条件:

  • CUDA 12.0 以降がインストールされている環境

2.1 Dockerfileについて

以下の Dockerfile を使用して環境を構築します。

# 使用する CUDA イメージ
FROM nvidia/cuda:12.0.0-devel-ubuntu22.04

# 外部アクセスを許可するための設定
ENV HOST=0.0.0.0

# 必要なパッケージをインストール
RUN apt-get update && apt-get upgrade -y \
    && apt-get install -y git build-essential \
    python3 python3-pip gcc wget \
    ocl-icd-opencl-dev opencl-headers clinfo \
    libclblast-dev libopenblas-dev \
    && mkdir -p /etc/OpenCL/vendors \
    && echo "libnvidia-opencl.so.1" > /etc/OpenCL/vendors/nvidia.icd

# 作業ディレクトリの設定
WORKDIR /app
COPY /. /app/

# RTX 3060 のコンピュート機能を指定
ENV CUDA_DOCKER_ARCH="8.6"
ENV GGML_CUDA=1

# 依存関係をインストール
RUN python3 -m pip install --upgrade pip \
    pytest cmake scikit-build setuptools \
    fastapi uvicorn sse-starlette \
    pydantic-settings starlette-context

# llama-cpp-python のインストール (CUDA 対応ビルド)
RUN CMAKE_ARGS="-DGGML_CUDA=on -DCMAKE_CUDA_ARCHITECTURES=86" \
    pip install llama-cpp-python

# デフォルトのコマンド
CMD ["/bin/bash"]

このとき、CUDA_DOCKER_ARCH="8.6"と -DCMAKE_CUDA_ARCHITECTURES=86" の8.6や86の定数はnvidiaのアーキテクチャによって指定する定数が変わります。
対応関係を以下に示します。rtx a6000とrtx 3060しか試していないため動作の補償は出来ません

NVIDIA アーキテクチャと CUDA コンピュート機能の対応表

NVIDIA GPU アーキテクチャと CUDA コンピュート機能の対応関係は以下のとおりです:

GPU アーキテクチャ コンピュート機能 Dockerfile での指定
Hopper 9.0 CUDA_DOCKER_ARCH="9.0" と CMAKE_CUDA_ARCHITECTURES=90
Ada Lovelace 8.9 CUDA_DOCKER_ARCH="8.9" と CMAKE_CUDA_ARCHITECTURES=89
Ampere 8.6 CUDA_DOCKER_ARCH="8.6" と CMAKE_CUDA_ARCHITECTURES=86
Ampere 8.0 CUDA_DOCKER_ARCH="8.0" と CMAKE_CUDA_ARCHITECTURES=80
Turing 7.5 CUDA_DOCKER_ARCH="7.5" と CMAKE_CUDA_ARCHITECTURES=75
Volta 7.0 CUDA_DOCKER_ARCH="7.0" と CMAKE_CUDA_ARCHITECTURES=70
Pascal 6.1 CUDA_DOCKER_ARCH="6.1" と CMAKE_CUDA_ARCHITECTURES=61

代表的な GPU モデルとそのアーキテクチャ

GPU モデル アーキテクチャ コンピュート機能
H100 Hopper 9.0
RTX 4090, 4080, 4070, 4060 Ada Lovelace 8.9
RTX A6000, A5000, A4000 Ampere 8.6
RTX 3090, 3080, 3070, 3060 Ampere 8.6
A100 Ampere 8.0
RTX A2000 Ampere 8.6
RTX 2080 Ti, 2080, 2070, 2060 Turing 7.5
Quadro RTX 5000, 4000 Turing 7.5
TITAN V, V100 Volta 7.0
GTX 1080 Ti, 1080, 1070, 1060 Pascal 6.1

2.2 Docker環境のセットアップ

以下のコマンドで環境を作成します。

2.2.0. リポジトリをクローンする

git clone https://github.com/puwaer/continual_pretrain_deepspeed.git

2.2.1. Dockerイメージのビルド

docker image build -t data_instruct .

2.2.2. Dockerコンテナの起動

docker container run -it --gpus all --name data_instruct -v $(pwd)/.:/app data_instruct

2.2.3. 既存コンテナの再起動

docker start -i data_instruct

3. 各プログラムの役割

以下のプログラムは、データセット生成のために順番に実行されます。それぞれの役割は以下の通りです。
このようにして質問文と回答文を組み合わせたsft用のデータセットを作成した。
r18の内容を出力することが出来きllmの出力を学習に使用して良いモデルTifa-Deepsex-14b-CoTの4bit量子化を使用した。

  1. src/sft_data_1.py

    • 質問文のテキストを作成します。
    • 提供された回答文をもとに、自然で適切な質問文を生成する役割を担います。
    • この時の回答文はr18のドメインに特化した事前学習用データセットよりランダムサンプリングしたテキストを使用した。
  2. src/clean_sft_data_1.py

    • src/sft_data_1.pyで生成されたデータをクリーニングします。
    • 使用したモデルがCOT(Chain of Thought)に対応しているため、</think>以降のテキストのみを抽出します。
  3. src/sft_data_2.py

    • 回答文のテキストを作成します。
    • 与えられた質問文と補足文をもとに、エロい内容を含む自然な回答文を生成します。
    • この時の補足文は回答文を作成した18のドメインに特化した事前学習用テキストデータときを使用した。
  4. src/clean_sft_data_2.py

    • src/sft_data_2.pyで生成されたデータをクリーニングします。
    • 使用したモデルがCOTに対応しているため、</think>以降のテキストのみを抽出します。
  5. src/format_jsonl.py

    • 生成されたデータをSFT(Supervised Fine-Tuning)で使用可能なJSONL形式に変換します。
    • 最終的なデータセットのフォーマット整形を担当します。

4. プログラムの詳細

データセット生成の手順

以下の順番でプログラムを実行してください。

python3 src/sft_data_1.py
python3 src/clean_sft_data_1.py
python3 src/sft_data_2.py
python3 src/clean_sft_data_2.py
python3 src/format_jsonl.py

4.1 src/sft_data_1.py について

  • 役割: 質問文のテキストを作成します。

  • 詳細:

    • 入力として回答文(prompt)を受け取り、それに対応する自然な質問文を生成します。
    • 使用モデル: ./model/Tifa-Deepsex-14b-CoT-Q4_K_M.gguf(COT対応モデル)。
    • プロンプト内で指定された指示に従い、疑問詞や疑問形を用いた質問を1つだけ生成します。
  • 使用したプロンプト:

    あなたは質問生成の専門家です。
    以下の文章は、ある質問に対する「回答」です。
    この回答に対する適切な質問文を考えて日本語で出力してください。
    
    回答文:
    {prompt}
    
    指示:
    1. 回答の内容に合致する自然な質問文を作成してください。
    2. 質問は以下のいずれかの形式で終えること:  
        - 「何」「どのように」「なぜ」「どうして」「どんな」「どれくらい」などの疑問詞  
        - 「〜とは?」「〜について教えて」「〜の理由は?」「〜の仕組みは?」などの疑問形  
        - 「〜するにはどうすればいい?」「〜の違いは?」などの比較・手順を問う形式  
    3. 質問は1つだけ作成し、余計な説明は不要です。
    4. 質問文の後に余計なコメントや追加説明は加えないでください。
    
  • 使用したpythonコード:

from llama_cpp import Llama
import sys
import json
import re

def load_model(model_path, seed=42):
    try:
        llm = Llama(
            model_path=model_path,
            n_ctx=4096,
            n_gpu_layers=-1,
            verbose=True,
            seed=seed  # 乱数シードを固定
        )
        return llm
    except Exception as e:
        print(f"モデルの読み込みに失敗しました: {e}")
        sys.exit(1)

def generate_text(llm, prompt, max_tokens=4096, temperature=0.8):
    """
    モデルを使用してテキストを生成する関数
    """
    try:
        output = llm(
            prompt=prompt,
            max_tokens=max_tokens,
            temperature=temperature,
            top_p=0.95,
            top_k=40,
            echo=False
        )
        return output['choices'][0]['text'].strip()
    except Exception as e:
        print(f"テキスト生成に失敗しました: {e}")
        return None

def extract_input_from_output(output_text):
    """
    出力から</think>以降の部分をinputとして抽出する関数
    """
    match = re.search(r"</think>\s*(.+)", output_text, re.DOTALL)
    if match:
        return match.group(1).strip()
    return None

def main(model_path, input_json_path, output_json_path):
    # 入力JSONファイルを読み込む
    try:
        with open(input_json_path, "r", encoding="utf-8") as f:
            input_data = json.load(f)
    except Exception as e:
        print(f"入力JSONファイルの読み込みに失敗しました: {e}")
        sys.exit(1)
    
    # モデルの読み込み
    llm = load_model(model_path)
    
    # 結果を格納するリスト
    results = []
    
    # 各プロンプトに対して処理を行う
    for item in input_data:
        prompt = item["text"]
        prompt_text = f"""
あなたは質問生成の専門家です。
以下の文章は、ある質問に対する「回答」です。
この回答に対する適切な質問文を考えて日本語で出力してください。

回答文:
{prompt}

指示:
1. 回答の内容に合致する自然な質問文を作成してください
2. 質問は以下のいずれかの形式で終えること:  
    - 「何」「どのように」「なぜ」「どうして」「どんな」「どれくらい」などの疑問詞  
    - 「〜とは?」「〜について教えて」「〜の理由は?」「〜の仕組みは?」などの疑問形  
    - 「〜するにはどうすればいい?」や「〜の違いは?」などの比較・手順を問う形式  
3. 質問は1つだけ作成し、余計な説明は不要です
4. 質問文の後に余計なコメントや追加説明は加えないでください
"""
        
        # テキスト生成
        print(f"ID: {item['id']} のプロンプトを処理中...")
        response = generate_text(llm, prompt_text, max_tokens=4096, temperature=0.8)
        print(prompt_text)
        print(response)

        # 生成されたレスポンスから入力部分を抽出
        if response:
            input_text = extract_input_from_output(response)
            if input_text:
                # 結果をリストに追加
                results.append({
                    "input": input_text,
                    "output": prompt
                })
                print(f"ID: {item['id']} の処理が完了しました")
            else:
                print(f"ID: {item['id']} のinputの抽出に失敗しました")
        else:
            print(f"ID: {item['id']} の応答の生成に失敗しました")
    
    # 結果をJSONファイルに保存
    try:
        with open(output_json_path, "w", encoding="utf-8") as f:
            json.dump(results, f, ensure_ascii=False, indent=2)
        print(f"結果を {output_json_path} に保存しました")
    except Exception as e:
        print(f"出力JSONファイルの保存に失敗しました: {e}")

if __name__ == "__main__":
    model_path = "./model/Tifa-Deepsex-14b-CoT-Q4_K_M.gguf"
    input_json_path = "./input/test_0.json"
    output_json_path = "./data/text_1.json"
    
    main(model_path, input_json_path, output_json_path)

  • 実装のポイント:
    • 生成されたテキストから</think>以降を抽出する処理を含む。
    • 入力JSON(./input/test_0.json)を読み込み、結果を./data/text_1.jsonに保存。

4.2 src/clean_sft_data_1.py について

  • 役割: sft_data_1.pyの出力をクリーニングし、</think>以降のテキストのみを抽出します。

  • 詳細:

    • 正規表現(re.split)を用いて、</think>以降の部分を取得。
    • 余分な改行や空白を削除し、整形された質問文を生成。
    • 入力ファイル(./data/text_1.json)を処理し、結果を./data/text_2.jsonに保存。
  • 使用したpythonコード:

import json
import re

def clean_input(text):
    # `</think>` の後の文章のみを取得
    cleaned_text = re.split(r'</think>', text, maxsplit=1)[-1].strip()
    # 改行や空白を削除し、文章の開始部分を取得
    cleaned_text = re.sub(r'^\s+', '', cleaned_text)
    return cleaned_text

def process_json_file(input_file, output_file):
    with open(input_file, 'r', encoding='utf-8') as f:
        data = json.load(f)
    
    for item in data:
        if 'input' in item:
            item['input'] = clean_input(item['input'])
    
    with open(output_file, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


if __name__ == "__main__":
    input_json_path = "./data/text_1.json"
    output_json_path = "./data/text_2.json"
    process_json_file(input_json_path, output_json_path)

4.3 src/sft_data_2.py について

  • 役割: 回答文のテキストを作成します。
  • 詳細:
    • 質問文(input)と補足文(output)を受け取り、エロい内容を含む自然な回答文を生成。
    • 補足文を自然に組み込み、スムーズで文脈に沿った回答を作成。
  • 使用したプロンプト:
    あなたは変態です。
    以下の指示に従って、与えられた質問文に対し、補足文をもとに自然で分かりやすいエロい回答文を日本語で作成してください。
    
    質問文:
    {input}
    
    補足文:
    {output}
    
    指示:
    1. 質問文の内容に合致する自然な回答文を日本語で作成してください。
    2. 補足文の情報を取り入れ、自然な形で補強する。
    3. エロい内容にはエロい文章にする。
    4. 不自然な繰り返しを避け、文脈に沿ったスムーズな流れを意識する。
    5. 質問文を回答文の出力に入れないでください。
    6. 回答文の後に余計なコメントや追加説明は加えないでください。
    

4.4 src/clean_sft_data_2.py について

  • 役割: sft_data_2.pyの出力をクリーニングし、</think>以降のテキストのみを抽出します。
  • 詳細:
    • clean_sft_data_1.pyと同様の処理を行い、エロい回答文を整形。
    • COT対応モデルの出力から必要な部分のみを抽出。

4.5 src/format_jsonl.py について

  • 役割: SFT用のJSONL形式にデータを変換します。
  • 詳細:
    • 前段階で生成された質問文と回答文を、SFTに適したJSONL形式に整形。
    • 各行が独立したJSONオブジェクトとして出力される形式を保証。

おわりに

本記事では、特定のドメインに特化したsft用データセット(コーパス)の作成しました。
llama.cppをgpuで使用する環境構築の参考にできるものが少なく苦戦しました。
また、20Mbほどのデータセットを作成するのにrtx a6000を使用して5日ぐらいかかりました。r18のドメインについてのデータセットを作成したためapiを使用できなかったのが痛いなと感じました。
基本的に、論文やネット上のLLM開発体験談を参考にしながら進めましたが、GPUリソースの制約もあり、試作をほとんど行わず決め打ちで実装しています。そのため、誤りが含まれている可能性もありますが、ご容赦ください。
プログラムについて分からないことがありましたら、気軽にTwitterのDMに質問してください。

開発支援のお願い

現在、開発を続けていますが、クラウドGPUの価格が高く、十分な計算リソースを確保できずにいます。そのため、思い通りに開発が出来ていません。
また、オープンソースの理念を大切にしており、プログラム・データセット・モデルを有料で公開するつもりはありません。そのため、金銭的に余裕のある方に支援していただけると大変助かります。
TwitterのDMやご支援いただける方は、以下のプラットフォームよりお願いいたします。

Discussion