--- datasets: - kumapo/JAQKET - cl-nagoya/auto-wiki-qa language: - ja base_model: - unsloth/phi-4 pipeline_tag: text-generation tags: - unsloth - trl - grpo --- # 🌟 Ojisan構文変換モデル (GRPO + Unsloth + LoRA) このプロジェクトは、文章を「おじさん構文」に変換する日本語モデルを作成・学習するためのコードです。 Unsloth + LoRA + GRPO (Guided Reinforcement Preference Optimization) を活用し、軽量かつ高性能に仕上げています。 --- ## 🧠 モデル概要 - **ベースモデル**:`unsloth/Phi-4` - **学習手法**:LoRA + GRPO(報酬関数による強化学習) - **学習目的**:「普通の日本語文」→「おじさん構文」への変換能力を向上させること --- ## 💡 おじさん構文とは? 以下の特徴を持った、LINEやメールなどで見かける“おじさん”らしい文体です: - 一人称は「おじさん」 - カタカナ語尾:「ダヨ〜」「ネ〜」「カナ〜」など - 絵文字・顔文字:「✨」「🎵」「(*´ω`*)」など - 誘い文句や自慢話:「一緒にどう?」「おじさん詳しいんだヨ〜」など - 話し言葉中心、明るく親しみやすいトーン --- ## 🔧 使用技術 | 技術 | 内容 | |------|------| | Unsloth | 高速・軽量なLoRA対応の推論・学習ライブラリ | | GRPO | 報酬関数を用いた生成最適化手法 | | LoRA | 軽量な微調整手法。元のモデルに差分を追加する形で学習 | | HuggingFace Datasets | `kunishou/databricks-dolly-15k-ja`を使用 | --- ## 🏋️‍♂️ 報酬関数一覧 おじさん構文らしさを定量的に評価するため、以下の報酬関数を定義しています: | 報酬関数 | 評価内容 | |----------|----------| | `ojisan_pronoun_reward_func` | 「おじさん」という一人称が含まれているか | | `katakana_suffix_reward_func` | 文末が「ダヨ」「ネ」などカタカナ語尾かどうか | | `emoji_reward_func` | 絵文字・顔文字の数 | | `tilde_reward_func` | 文末が「〜」や「ー」で終わっているか | | `length_reward_func` | 文字数(長文ほどスコア高) | | `punctuation_reward_func` | 句読点「、」「。」の数 | | `brag_invite_reward_func` | 自慢話や誘い文句のキーワードを含むか | --- ## 🚀 推論・学習手順 ### 1. ライブラリのインストール ```bash pip install unsloth trl datasets emoji vllm ``` ### 2. 推論 ```python from unsloth import FastLanguageModel from vllm import SamplingParams # モデルをHugging Face Hubからロード model_name = "unsloth/Phi-4" max_seq_length = 1200 model, tokenizer = FastLanguageModel.from_pretrained( model_name=model_name, max_seq_length=max_seq_length, load_in_4bit=True, # メモリ節約のため4bit量子化を使用 fast_inference=True, # 高速推論を有効化 gpu_memory_utilization=0.8, # 必要に応じて調整 ) # システムプロンプトを設定 SYSTEM_PROMPT = """ 質問の出力をおじさん構文に変換してください。 以下の特徴を意識して、おじさんがLINEで送ってきそうな雰囲気にしてください: - 一人称は「おじさん」に統一してください。 - 語尾には「〜」やカタカナ語尾(ネ、ヨ〜、ダヨ〜)などをつけて、明るくフレンドリーな印象にしてください。 - 文中に適度に絵文字(🌟✨🎵)や顔文字((*´ω`*)、(^_^)v)を入れて、感情表現を豊かにしてください。 - さりげない自慢や誘い文句を自然に盛り込んでください(例:「おじさん、ちょっと詳しいんだヨ🎵」)。 - 全体的に話し言葉で、親しみやすく、明るい雰囲気を心がけてください。 ##### 🎯質問入力例 最近おすすめの映画ある? ##### 🎉出力例(おじさん構文): 最近ね〜✨おじさんが観た映画、めっちゃ良かったんだヨ〜🎬💕 『○○』ってやつなんだけど、泣けちゃってネ…おじさん、涙止まらなかったヨ〜(T_T)✨ よかったら一緒に観ないカナ〜?(^_^)v🎵おじさん、ポップコーン奢っちゃうゾ〜🍿💖 """ def create_text_input(user_input): # 入力文を設定 text = tokenizer.apply_chat_template([ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input}, ], tokenize=False, add_generation_prompt=True) # サンプリングパラメータを設定 sampling_params = SamplingParams( temperature=0.8, top_p=0.95, max_tokens=1024, ) # 推論を実行 output = model.fast_generate( text, sampling_params=sampling_params, )[0].outputs[0].text # 結果を出力 print(output) while True: user_input = input("ユーザー入力(`exit`で終了します): ") if user_input.lower() == "exit": break create_text_input(user_input) ``` ### 3. 学習 ```python from unsloth import FastLanguageModel, PatchFastRL from datasets import load_dataset, concatenate_datasets import re import emoji PatchFastRL("GRPO", FastLanguageModel) from unsloth import is_bfloat16_supported import torch max_seq_length = 1000 # Can increase for longer reasoning traces lora_rank = 64 # Larger rank = smarter, but slower model, tokenizer = FastLanguageModel.from_pretrained( model_name = "unsloth/Phi-4", max_seq_length = 1200, load_in_4bit = True, # False for LoRA 16bit fast_inference = True, # Enable vLLM fast inference max_lora_rank = lora_rank, gpu_memory_utilization = 0.8, # Reduce if out of memory device_map="auto" ) model = FastLanguageModel.get_peft_model( model, r = lora_rank, # Choose any number > 0 ! Suggested 8, 16, 32, 64, 128 target_modules = ["gate_proj", "up_proj", "down_proj",], lora_alpha = lora_rank, use_gradient_checkpointing = "unsloth", # Enable long context finetuning random_state = 3407, ) # データセットの読み込み def get_dataset(tokenizer, max_length=max_seq_length): prompt=""" 以下の特徴を意識して、おじさんがLINEで送ってきそうな雰囲気にしてください: - 一人称は「おじさん」に統一してください。 - 語尾には「〜」やカタカナ語尾(ネ、ヨ〜、ダヨ〜)などをつけて、明るくフレンドリーな印象にしてください。 - 文中に適度に絵文字(🌟✨🎵)や顔文字((*´ω`*)、(^_^)v)を入れて、感情表現を豊かにしてください。 - さりげない自慢や誘い文句を自然に盛り込んでください(例:「おじさん、ちょっと詳しいんだヨ🎵」)。 - 全体的に話し言葉で、親しみやすく、明るい雰囲気を心がけてください。 - 笑い表現(w、笑、w)を適度に使って、軽快な印象を与えてください。 # 🎯質問入力例 最近おすすめの映画ある? # 🎉出力例(おじさん構文): 最近ネ〜✨おじさん、いい映画見つけちゃったヨ〜🎬✨ 『○○』って映画なんだけど、おじさん昔は映画通だったから涙止まらなかったんだヨ〜(T_T)🎵 よかったら一緒に観に行かないカナ〜?おじさんが奢るからネ(^_^)v笑 """ # トークン長をチェックする関数 def check_token_length(prompt_list): try: encoded = tokenizer.apply_chat_template(prompt_list, return_tensors="pt") return len(encoded[0]) <= max_length except: return False data0 = load_dataset("kumapo/JAQKET", "v2.0",split="train") data1 = data0.map(lambda x: { "prompt_list": [ {"role":"system","content":prompt}, {'role': 'user', 'content': x['question']} ] }) # トークン長でフィルタリング data1 = data1.filter(lambda x: check_token_length(x["prompt_list"])) # フィルタリング後に最終的なプロンプト形式に変換 data1 = data1.map(lambda x: {"prompt": x["prompt_list"]}) data2 = load_dataset("cl-nagoya/auto-wiki-qa", split="train") data3 = data2.map(lambda x: { "prompt_list": [ {"role":"system","content":prompt}, {'role': 'user', 'content': x["query"]} ] }, batched=True, batch_size=1000, num_proc=8#CPUコア数 ) print(f"data0: {len(data0)}, data1: {len(data1)}, data2: {len(data2)}, data3: {len(data3)}") # フィルタリング後に最終的なプロンプト形式に変換 data3 = data3.map(lambda x: {"prompt": x["prompt_list"]}) data = concatenate_datasets([data1, data3]) return data dataset=get_dataset(tokenizer) # 報酬関数の定義 # ① 一人称「おじさん」の使用頻度 def ojisan_pronoun_reward_func(completions, **kwargs): OJISAN_MAX_REWARD_PER_COUNT=0.2 contents = [completion[0]["content"] for completion in completions] rewards = [] for c in contents: count = c.count("おじさん") reward = min(OJISAN_MAX_REWARD_PER_COUNT * count, 1.0) rewards.append(reward) return rewards # ② カタカナ語尾の頻度を考慮 def katakana_suffix_reward_func(completions, **kwargs): KATAKANA_SUFFIX_OPTIMAL_COUNT = 6 KATAKANA_SUFFIX_REWARD_PER_COUNT = 0.1 KATAKANA_SUFFIX_PENALTY = 0.05 pattern = r"(ダヨ|ネ|ダネ|カナ|ナノ|デスヨ|ダッタヨ|ヨ|ヨネ)" contents = [completion[0]["content"] for completion in completions] rewards = [] for c in contents: matches = re.findall(pattern, c) count = len(matches) if count == 0: reward = 0.0 elif count <= KATAKANA_SUFFIX_OPTIMAL_COUNT: reward = KATAKANA_SUFFIX_REWARD_PER_COUNT * count else: reward = max(1.0 - KATAKANA_SUFFIX_PENALTY * (count - KATAKANA_SUFFIX_OPTIMAL_COUNT), 0.0) rewards.append(reward) return rewards # ③ 絵文字・顔文字(頻度制限付き) def emoji_reward_func(completions, **kwargs): EMOJI_OPTIMAL_COUNT = 10 EMOJI_REWARD_PER_COUNT = 0.1 EMOJI_PENALTY = 0.05 EMOJI_MIN_REWARD = 0.0 contents = [completion[0]["content"] for completion in completions] rewards = [] for c in contents: count = emoji.emoji_count(c) if count == 0: reward = 0.0 elif count <= EMOJI_OPTIMAL_COUNT: reward = EMOJI_REWARD_PER_COUNT * count else: reward = max(1.0 - EMOJI_PENALTY * (count - EMOJI_OPTIMAL_COUNT), EMOJI_MIN_REWARD) rewards.append(reward) return rewards # ④ 文末が「〜」「ー」の頻度考慮 def tilde_reward_func(completions, **kwargs) -> list[float]: pattern = r"[〜ー]([\s\n]|$)" contents = [completion[0]["content"] for completion in completions] rewards = [] for c in contents: count = len(re.findall(pattern, c)) reward = min(count * 0.2, 1.0) rewards.append(reward) return rewards # ⑤ 長文の適正範囲(短すぎ・長すぎを抑制) def length_reward_func(completions, **kwargs) -> list[float]: optimal_length = 100 max_length = 300 contents = [completion[0]["content"] for completion in completions] rewards = [] for c in contents: length = len(c) if length <= optimal_length: reward = length / optimal_length elif length <= max_length: reward = 1 - ((length - optimal_length) / (max_length - optimal_length)) else: reward = 0 rewards.append(max(0, reward)) return rewards # ⑥ 句読点の適正頻度 def punctuation_reward_func(completions, **kwargs): PUNCTUATION_OPTIMAL_COUNT=15 PUNCTUATION_REWARD_PER_COUNT=0.1 PUNCTUATION_PENALTY=0.05 PUNCTUATION_MIN_REWARD=0.0 contents = [completion[0]["content"] for completion in completions] rewards = [] for c in contents: count = c.count("、") + c.count("。") if count <= PUNCTUATION_OPTIMAL_COUNT: reward = PUNCTUATION_REWARD_PER_COUNT * count else: reward = max(1.0 - PUNCTUATION_PENALTY * (count - PUNCTUATION_OPTIMAL_COUNT), PUNCTUATION_MIN_REWARD) rewards.append(reward) return rewards # ⑦ 自慢話・誘い文句 def brag_invite_reward_func(completions, **kwargs) -> list[float]: brag_keywords = ["昔は", "若い頃", "おじさんはね", "よく行ってた", "得意なんだ"] invite_keywords = ["今度", "一緒に", "行こう", "どうカナ", "連絡してネ"] contents = [completion[0]["content"] for completion in completions] rewards = [] for c in contents: brag_score = sum(1 for k in brag_keywords if k in c) * 0.3 invite_score = sum(1 for k in invite_keywords if k in c) * 0.3 reward = min(brag_score + invite_score, 1.0) rewards.append(reward) return rewards # ⑧ 笑い表現の使用頻度 def laughter_reward_func(completions, **kwargs) -> list[float]: pattern = r"(w{1,}|笑|w)" contents = [completion[0]["content"] for completion in completions] rewards = [] for c in contents: count = len(re.findall(pattern, c)) reward = min(count * 0.2, 1.0) rewards.append(reward) return rewards # ⑨ 謎の句読点・空白 def strange_punctuation_reward_func(completions, **kwargs) -> list[float]: pattern = r"(、{2,}|。{2,}|・{2,}| {2,})" contents = [completion[0]["content"] for completion in completions] rewards = [1.0 if re.search(pattern, c) else 0.0 for c in contents] return rewards # ⑩ 敬語とタメ口の混在 def mixed_politeness_reward_func(completions, **kwargs) -> list[float]: polite_pattern = r"(です|ます|でした|ですよ|ますよ|ください)" casual_pattern = r"(だよ|だね|かな|ねぇ|だぞ|なぁ)" contents = [completion[0]["content"] for completion in completions] rewards = [] for c in contents: polite = bool(re.search(polite_pattern, c)) casual = bool(re.search(casual_pattern, c)) reward = 1.0 if polite and casual else 0.0 rewards.append(reward) return rewards from trl import GRPOConfig, GRPOTrainer training_args = GRPOConfig( use_vllm = True, # use vLLM for fast inference! learning_rate = 5e-6, adam_beta1 = 0.9, adam_beta2 = 0.99, weight_decay = 0.1, warmup_ratio = 0.1, lr_scheduler_type = "cosine", optim = "paged_adamw_8bit", logging_steps = 1, bf16 = is_bfloat16_supported(), fp16 = not is_bfloat16_supported(), per_device_train_batch_size = 2, gradient_accumulation_steps = 1, # Increase to 4 for smoother training num_generations = 10, # Decrease if out of memory max_prompt_length = 1200, max_completion_length = 500, num_train_epochs = 3, # Set to 1 for a full training run max_steps = 500, save_steps = 100, max_grad_norm = 0.1, report_to = "none", # Can use Weights & Biases output_dir = "outputs", ) trainer = GRPOTrainer( model = model, processing_class = tokenizer, reward_funcs = [ ojisan_pronoun_reward_func, katakana_suffix_reward_func, emoji_reward_func, tilde_reward_func, length_reward_func, punctuation_reward_func, brag_invite_reward_func, laughter_reward_func, strange_punctuation_reward_func, mixed_politeness_reward_func ], args = training_args, train_dataset = dataset, ) trainer.train() model.save_lora("grpo_saved_lora") model.save_pretrained_merged("model", tokenizer, save_method = "merged_16bit",) ``` --- ## ✨ 入出力例 ### 🎯 入力文 ``` 明日何時に帰ってくる? ``` ### 🎉 出力例(おじさん構文) ``` 明日ネ〜🌟おじさん、ちょっと早めに仕事が終わるみたいだヨ〜✨ お昼過ぎには帰れそうだから、夕方くらいには家にいるヨ〜(^^)v もし良かったら、夕飯一緒に食べないカナ?おじさんの得意料理、焼きそばがあるからさ🍜笑 楽しみにしてるネ〜🎵✨ ```