| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import json |
| import os |
| import random |
| import re |
| from dataclasses import dataclass, field |
| from datetime import datetime |
| from typing import List, Optional |
|
|
| import numpy as np |
| import torch |
| from datasets import Dataset |
| from rouge_score import rouge_scorer |
| from src.time_r1 import TimeR1_Trainer_ft |
| from torch.utils.data import DataLoader, SequentialSampler |
| from tqdm import tqdm |
| from transformers import ( |
| TrainerCallback, |
| TrainerControl, |
| TrainerState, |
| TrainingArguments, |
| ) |
| from trl import GRPOConfig, ModelConfig, ScriptArguments, TrlParser, get_peft_config |
|
|
|
|
| @dataclass |
| class MY_GRPOConfig(GRPOConfig): |
| fix_vit: bool = field( |
| default=False, |
| metadata={"help": "Whether to fix the ViT model"}, |
| ) |
|
|
| slide_window: bool = field( |
| default=False, |
| metadata={"help": "Whether to use slide window"}, |
| ) |
|
|
| prompt_type: str = field( |
| default="v1", |
| metadata={"help": "Prompt type. Possible values: 'v1', 'v2', 'v3'"}, |
| ) |
|
|
| use_grpo: bool = field( |
| default=False, |
| metadata={"help": "Whether to use GRPO"}, |
| ) |
|
|
|
|
| @dataclass |
| class GRPOScriptArguments(ScriptArguments): |
| """ |
| Script arguments for the GRPO training script. |
| |
| Args: |
| reward_funcs (`list[str]`): |
| List of reward functions. Possible values: 'iou', 'format'. |
| """ |
|
|
| reward_funcs: list[str] = field( |
| default_factory=lambda: ["iou", "format"], |
| metadata={"help": "List of reward functions. Possible values: 'iou', 'format'"}, |
| ) |
| max_pixels: Optional[int] = field( |
| default=12845056, |
| metadata={"help": "Maximum number of pixels for the image"}, |
| ) |
| min_pixels: Optional[int] = field( |
| default=3136, |
| metadata={"help": "Minimum number of pixels for the image"}, |
| ) |
|
|
| train_data_path: str = field( |
| default="./dataset/finetune/charades/Charades/charades_annotation/train.json", |
| metadata={"help": "Path to the training data JSON file."}, |
| ) |
|
|
| eval_data_path: str = field( |
| default="./dataset/finetune/charades/Charades/charades_annotation/val.json", |
| metadata={"help": "Path to the evaluation data JSON file."}, |
| ) |
|
|
| video_folder: str = field( |
| default="./dataset/finetune/charades/Charades/Charades_v1", |
| metadata={"help": "Path to the folder containing video files."}, |
| ) |
|
|
| is_curriculum_learning: bool = field( |
| default=False, |
| metadata={"help": "Whether to use curriculum learning."}, |
| ) |
| preprocessed_data_path: Optional[str] = ( |
| field( |
| default="", |
| metadata={ |
| "help": "Path to the preprocessed dataset directory. If provided, load preprocessed data instead of raw videos." |
| }, |
| ) |
| ) |
|
|
|
|
| def parse_timestamp_output(output_string): |
| """Parses timestamp output, similar to the example code.""" |
| |
| answer_matches = re.findall(r"<answer>(.*?)</answer>", output_string, re.DOTALL) |
|
|
| if not answer_matches: |
| return None |
|
|
| |
| last_answer_content = answer_matches[-1] |
| print("last_answer_content:", last_answer_content) |
|
|
| matches = re.findall( |
| r"(\d+\.?\d*) (to|and) (\d+\.?\d*)", last_answer_content, re.IGNORECASE |
| ) |
| if not matches: |
| return None |
| last_match = matches[-1] |
| start_time = float(last_match[0]) |
| end_time = float(last_match[2]) |
| return start_time, end_time |
|
|
|
|
| def iou_timestamp_reward( |
| completions, solution, **kwargs |
| ): |
| """Reward function that calculates IoU between predicted and ground truth timestamps.""" |
| rewards = [] |
| current_time = datetime.now().strftime("%d-%H-%M-%S-%f") |
| for content, sol in zip(completions, solution): |
|
|
| reward = 0.0 |
| parsed_times = parse_timestamp_output(content) |
| start_time, end_time = 0, 0 |
| gt_start, gt_end = sol |
| s, e = gt_start, gt_end |
| if parsed_times: |
| start_time, end_time = parsed_times |
| from_number = start_time |
| to_number = end_time |
|
|
| intersection = max(0, min(to_number, e) - max(from_number, s)) |
| union = max(to_number, e) - min(from_number, s) |
| if union > 0: |
| iou = intersection / union |
|
|
| reward = iou |
|
|
| rewards.append(reward) |
|
|
| if os.getenv("DEBUG_MODE") == "true": |
| log_path = os.getenv("LOG_PATH") |
| with open(log_path, "a", encoding="utf-8") as f: |
| f.write(f"Content: {content}\n") |
| f.write(f"pred second: {str(start_time)}, {str(end_time)}\n") |
| f.write(f"gt second: {str(gt_start)}, {str(gt_end)}\n") |
| f.write( |
| f"------------- {current_time} IoU reward: {reward} -------------\n" |
| ) |
|
|
| return rewards |
|
|
|
|
| def iou_timestamp_reward_v2( |
| completions, solution, **kwargs |
| ): |
| """Reward function that calculates IoU between predicted and ground truth timestamps.""" |
| rewards = [] |
| current_time = datetime.now().strftime("%d-%H-%M-%S-%f") |
| durations = kwargs.get("durations") |
| for content, sol, duration in zip( |
| completions, solution, durations |
| ): |
|
|
| reward = 0.0 |
| parsed_times = parse_timestamp_output(content) |
| start_time, end_time = 0, 0 |
| gt_start, gt_end = sol |
| s, e = gt_start, gt_end |
| if parsed_times: |
| start_time, end_time = parsed_times |
| from_number = start_time |
| to_number = end_time |
|
|
| intersection = max(0, min(to_number, e) - max(from_number, s)) |
| union = max(to_number, e) - min(from_number, s) |
| if union > 0: |
| iou = intersection / union |
|
|
| gt_start_norm = 1.0 * s / duration |
| gt_end_norm = 1.0 * e / duration |
| pred_start_norm = 1.0 * start_time / duration |
| pred_end_norm = 1.0 * end_time / duration |
| reward = ( |
| iou |
| * (1 - abs(gt_start_norm - pred_start_norm)) |
| * (1 - abs(gt_end_norm - pred_end_norm)) |
| ) |
|
|
| rewards.append(reward) |
|
|
| if os.getenv("DEBUG_MODE") == "true": |
| log_path = os.getenv("LOG_PATH") |
| with open(log_path, "a", encoding="utf-8") as f: |
| f.write(f"Content: {content}\n") |
| f.write(f"pred second: {str(start_time)}, {str(end_time)}\n") |
| f.write(f"gt second: {str(gt_start)}, {str(gt_end)}\n") |
| f.write( |
| f"------------- {current_time} IoU reward: {reward} -------------\n" |
| ) |
|
|
| return rewards |
|
|
|
|
| def mqa_answer_reward( |
| completions, solution, task_type, **kwargs |
| ): |
| """Reward function that calculates IoU between predicted and ground truth timestamps.""" |
|
|
| def extract_characters_regex(s): |
| s = s.strip() |
| answer_prefixes = [ |
| "The best answer is", |
| "The correct answer is", |
| "The answer is", |
| "The answer", |
| "The best option is", |
| "The correct option is", |
| "Best answer:" "Best option:", |
| ] |
| for answer_prefix in answer_prefixes: |
| s = s.replace(answer_prefix, "") |
|
|
| if len(s.split()) > 10 and not re.search("[ABCDEFG]", s): |
| return "" |
|
|
| matches = re.search(r"[ABCDEFG]", s) |
| if matches is None: |
| return "" |
| return matches[0] |
|
|
| rewards = [] |
| current_time = datetime.now().strftime("%d-%H-%M-%S-%f") |
| for content, sol, task in zip(completions, solution, task_type): |
| if task != "mqa": |
| rewards.append(None) |
| continue |
|
|
| reward = 0.0 |
|
|
| pattern_answer = r"<answer>(.*?)</answer>" |
|
|
| match_answer = re.search(pattern_answer, content, re.DOTALL) |
|
|
| answer = "" |
| if match_answer: |
| answer = match_answer.group(1) |
| if extract_characters_regex(answer) == extract_characters_regex(sol): |
| reward = 1.0 |
| rewards.append(reward) |
|
|
| if os.getenv("DEBUG_MODE") == "true": |
| log_path = os.getenv("LOG_PATH") |
| with open(log_path, "a", encoding="utf-8") as f: |
| f.write(f"Content: {content}\n") |
| f.write(f"pred: {answer}\n") |
| f.write(f"gt: {sol}\n") |
| f.write( |
| f"------------- {current_time} ACC reward: {reward} -------------\n" |
| ) |
|
|
| return rewards |
|
|
|
|
| def format_reward(completions, **kwargs): |
| """Reward function that checks if the completion has a specific format.""" |
| pattern = re.compile(r"<think>.*?</think>\s*<answer>.*?</answer>", re.DOTALL) |
| matches = [re.fullmatch(pattern, content.strip()) for content in completions] |
| print("matches:", matches) |
| return [1.0 if match else 0.0 for match in matches] |
|
|
|
|
| def extract_think_content(completion: str) -> Optional[str]: |
| """ |
| Extract the content within the last <think>...</think> block from the completion. |
| Use findall to get all matches, then take the last one. |
| """ |
| think_pattern = re.compile(r"<think>(.*?)</think>", re.DOTALL) |
| matches = think_pattern.findall(completion) |
| if matches: |
| return matches[-1].strip() |
| return None |
|
|
|
|
| def reward_timestep_pair( |
| completions: List[str], |
| weight: float = 0.2, |
| max_count: int = 1, |
| **kwargs, |
| ) -> List[float]: |
| rewards = [] |
| pair_pattern = re.compile( |
| r"<timestep>\s*(\d+\.?\d*)\s+to\s+(\d+\.?\d*)\s*</timestep>", |
| re.IGNORECASE | re.DOTALL, |
| ) |
|
|
| for completion in completions: |
| score = 0.0 |
| think_content = extract_think_content(completion) |
|
|
| if think_content: |
| pair_matches = pair_pattern.findall(think_content) |
| pair_count = len(pair_matches) |
| capped_count = min(pair_count, max_count) |
| score = weight * capped_count |
| else: |
| score = 0.0 |
|
|
| rewards.append(max(0.0, score)) |
|
|
| print("reward_timestep_pair", rewards) |
| return rewards |
|
|
|
|
| def reward_think_length( |
| completions: List[str], |
| weight: float = 0.001, |
| max_length: int = 500, |
| **kwargs, |
| ) -> List[float]: |
| rewards = [] |
| for completion in completions: |
| score = 0.0 |
| think_content = extract_think_content(completion) |
|
|
| if think_content: |
| think_length = len(think_content) |
| capped_length = min(think_length, max_length) |
| score = weight * capped_length |
| else: |
| score = 0.0 |
|
|
| rewards.append(max(0.0, score)) |
|
|
| return rewards |
|
|
|
|
| DEFAULT_STRUCTURE_KEYWORDS = [ |
| "analyze", |
| "compare", |
| "deduce", |
| "however", |
| "therefore", |
| "because", |
| "step", |
| "observe", |
| "notice", |
| "identify", |
| "wait", |
| ] |
|
|
|
|
| def reward_keyword_usage( |
| completions: List[str], |
| keywords: Optional[List[str]] = None, |
| weight: float = 0.1, |
| max_count: int = 2, |
| **kwargs, |
| ) -> List[float]: |
| if keywords is None: |
| keywords = DEFAULT_STRUCTURE_KEYWORDS |
| rewards = [] |
|
|
| for completion in completions: |
| score = 0.0 |
| think_content = extract_think_content(completion) |
|
|
| if think_content: |
| content_lower = think_content.lower() |
| keyword_count = sum(1 for word in keywords if word in content_lower) |
| capped_count = min(keyword_count, max_count) |
| score = weight * capped_count |
| else: |
| score = 0.0 |
|
|
| rewards.append(max(0.0, score)) |
|
|
| return rewards |
|
|
|
|
| def reward_paragraph_structure( |
| completions: List[str], |
| weight: float = 0.05, |
| max_paragraphs: int = 2, |
| **kwargs, |
| ) -> List[float]: |
| rewards = [] |
| for completion in completions: |
| score = 0.0 |
| think_content = extract_think_content(completion) |
|
|
| if think_content: |
| paragraphs = [p for p in think_content.split("\n") if p.strip()] |
| capped_paragraphs = min(len(paragraphs), max_paragraphs) |
| score = weight * capped_paragraphs |
| else: |
| score = 0.0 |
|
|
| rewards.append(max(0.0, score)) |
|
|
| return rewards |
|
|
|
|
| def diversity_reward_func(completions, num_generations=8, **kwargs): |
| if not completions: |
| return [] |
|
|
| batch_size = len(completions) // num_generations |
| diversity_rewards = [] |
| scorer = rouge_scorer.RougeScorer( |
| ["rougeL"], use_stemmer=True |
| ) |
|
|
| for i in range(batch_size): |
| group_start_idx = i * num_generations |
| group_end_idx = (i + 1) * num_generations |
| current_group_completions = completions[group_start_idx:group_end_idx] |
|
|
| group_rewards = np.zeros(num_generations) |
| for j in range(num_generations): |
| total_dissimilarity = 0 |
| count = 0 |
| for k in range(num_generations): |
| if j == k: |
| continue |
| try: |
| |
| score = scorer.score( |
| str(current_group_completions[j]), |
| str(current_group_completions[k]), |
| )["rougeL"].fmeasure |
| total_dissimilarity += 1.0 - score |
| count += 1 |
| except Exception as e: |
| print( |
| f"Warning: Error calculating ROUGE score: {e}. Skipping pair." |
| ) |
|
|
| if count > 0: |
| group_rewards[j] = total_dissimilarity / count |
| else: |
| group_rewards[j] = 0.0 |
|
|
| diversity_rewards.extend(group_rewards.tolist()) |
|
|
| print("diversity_rewards", diversity_rewards) |
| return diversity_rewards |
|
|
|
|
| def load_json_dataset_tg( |
| train_data_path, is_curriculum_learning=False, preprocessed_data_path=None |
| ): |
| def create_dataset_from_json(file_path, split_name): |
| with open(file_path, "r", encoding="utf-8") as f: |
| data = json.load(f) |
|
|
| examples = [] |
| for item in tqdm(data, desc=f"Processing {split_name} items"): |
| video_path = item.get( |
| "video" |
| ) |
| timestamps = item.get("timestamp") |
| sentence = item.get("sentence") |
| duration = item.get("duration") |
| video_start = item.get("video_start") |
| video_end = item.get("video_end") |
|
|
| sentence = sentence.strip().lower() |
| if sentence.endswith("."): |
| sentence = sentence[:-1] |
|
|
| if not os.path.isfile(video_path): |
| continue |
|
|
| example = { |
| "task_type": "tg", |
| "problem": sentence, |
| "choices": "", |
| "solution": ( |
| float(timestamps[0]), |
| float(timestamps[1]), |
| ), |
| "video_path": video_path, |
| "durations": duration, |
| "video_start": video_start, |
| "video_end": video_end, |
| "preprocessed_path": "", |
| } |
| examples.append(example) |
|
|
| if not examples: |
| return None |
|
|
| print("is_curriculum_learning:", is_curriculum_learning) |
| if not is_curriculum_learning: |
| random.shuffle(examples) |
|
|
| for i, ex in enumerate(examples[:5]): |
| print(f" sample {i+1}: {ex}") |
|
|
| dataset = Dataset.from_list(examples) |
|
|
| def __getitem__(self, idx): |
| example = dataset[idx] |
| return example |
|
|
| from types import MethodType |
|
|
| dataset.__getitem__ = MethodType(__getitem__, dataset) |
|
|
| return dataset |
|
|
| train_dataset = create_dataset_from_json(train_data_path, "train") |
|
|
| return train_dataset |
|
|
|
|
| def load_json_dataset( |
| train_data_path, video_folder, preprocessed_data_path=None |
| ): |
| def create_dataset_from_json(file_path, split_name): |
| with open(file_path, "r") as f: |
| data = json.load(f) |
| examples = [] |
| for video_id, video_data in tqdm(data.items()): |
| for sentence_id, (timestamps, sentence) in enumerate( |
| zip(video_data["timestamps"], video_data["sentences"]) |
| ): |
| sentence = sentence.strip().lower() |
| if sentence.endswith("."): |
| sentence = sentence[:-1] |
| video_filename_base = video_id |
| video_path = None |
| for ext in ["mp4", "mkv", "webm"]: |
| candidate_path = os.path.join( |
| video_folder, f"{video_filename_base}.{ext}" |
| ) |
| if os.path.isfile(candidate_path): |
| video_path = candidate_path |
| break |
| example = { |
| "problem": sentence, |
| "solution": (timestamps[0], timestamps[1]), |
| "video_path": video_path, |
| "durations": video_data["duration"], |
| "video_start": None, |
| "video_end": None, |
| "preprocessed_path": "", |
| } |
| example["preprocessed_path"] = os.path.join( |
| preprocessed_data_path, video_id |
| ) |
| if not os.path.exists(example["preprocessed_path"]): |
| print( |
| f"Warning: Preprocessed path not found for video_id: {video_id}" |
| ) |
|
|
| examples.append(example) |
|
|
| random.shuffle(examples) |
| print(len(examples)) |
| print(examples[:5]) |
| dataset = Dataset.from_list(examples) |
|
|
| def __getitem__( |
| self, idx |
| ): |
| example = dataset[idx] |
|
|
| |
| data_to_return = { |
| k: v for k, v in example.items() |
| } |
|
|
| if isinstance(example["preprocessed_path"], list): |
| data_to_return["video_inputs"] = [ |
| torch.load( |
| os.path.join(example["preprocessed_path"][0], "video_inputs.pt") |
| ) |
| ] |
| with open( |
| os.path.join(example["preprocessed_path"][0], "video_kwargs.json"), |
| "r", |
| ) as f: |
| data_to_return["video_kwargs"] = [json.load(f)] |
| else: |
| data_to_return["video_inputs"] = [ |
| torch.load( |
| os.path.join(example["preprocessed_path"], "video_inputs.pt") |
| ) |
| ] |
| with open( |
| os.path.join(example["preprocessed_path"], "video_kwargs.json"), "r" |
| ) as f: |
| data_to_return["video_kwargs"] = [json.load(f)] |
| data_to_return["use_preprocessed"] = [ |
| True |
| ] |
|
|
| return data_to_return |
|
|
| dataset.__getitem__ = __getitem__.__get__( |
| dataset, Dataset |
| ) |
|
|
| return dataset |
|
|
| train_dataset = create_dataset_from_json(train_data_path, "train") |
| return train_dataset |
|
|
|
|
| class SaveEpochEndCallback(TrainerCallback): |
| def on_epoch_end( |
| self, |
| args: TrainingArguments, |
| state: TrainerState, |
| control: TrainerControl, |
| **kwargs, |
| ): |
| if state.is_world_process_zero: |
| trainer = kwargs.get("trainer") |
| if trainer is None: |
| return |
|
|
| epoch_checkpoint_dir = os.path.join( |
| args.output_dir, f"epoch-{int(state.epoch)}" |
| ) |
|
|
| print( |
| f"\n{'='*20} Callback: Saving model checkpoint at end of epoch {int(state.epoch)} to {epoch_checkpoint_dir} {'='*20}\n" |
| ) |
| |
| trainer.save_model(epoch_checkpoint_dir) |
|
|
|
|
| def set_global_seed(seed_value: int): |
| random.seed(seed_value) |
| np.random.seed(seed_value) |
| torch.manual_seed(seed_value) |
| if torch.cuda.is_available(): |
| torch.cuda.manual_seed_all(seed_value) |
|
|
|
|
| def main(script_args, training_args, model_args): |
|
|
| set_global_seed(42) |
|
|
| |
| print(script_args.reward_funcs) |
| reward_funcs = [reward_funcs_registry[func] for func in script_args.reward_funcs] |
| metric_funcs = list(metric_funcs_registry.values()) |
|
|
| print(reward_funcs) |
| print(metric_funcs) |
| print("dataset_tg") |
| dataset = load_json_dataset( |
| script_args.train_data_path, |
| script_args.video_folder, |
| script_args.preprocessed_data_path, |
| ) |
|
|
| print(len(dataset)) |
|
|
| print(dataset.__getitem__(10).keys()) |
|
|
| trainer_cls = TimeR1_Trainer_ft |
| print("using: ", trainer_cls) |
|
|
| |
| trainer = trainer_cls( |
| model=model_args.model_name_or_path, |
| reward_funcs=reward_funcs, |
| metric_funcs=metric_funcs, |
| args=training_args, |
| train_dataset=dataset, |
| eval_dataset=None, |
| peft_config=get_peft_config(model_args), |
| attn_implementation=model_args.attn_implementation, |
| max_pixels=script_args.max_pixels, |
| min_pixels=script_args.min_pixels, |
| callbacks=[SaveEpochEndCallback()], |
| ) |
|
|
| |
| trainer.train() |
|
|
| |
| trainer.save_model(training_args.output_dir) |
| if training_args.push_to_hub: |
| trainer.push_to_hub(dataset_name=script_args.dataset_name) |
|
|
|
|
| reward_funcs_registry = { |
| "iou": iou_timestamp_reward, |
| "iou_v2": iou_timestamp_reward_v2, |
| "format": format_reward, |
| } |
|
|
| metric_funcs_registry = { |
| "reward_timestep_pair": reward_timestep_pair, |
| "reward_think_length": reward_think_length, |
| "reward_keyword_usage": reward_keyword_usage, |
| "reward_paragraph_structure": reward_paragraph_structure, |
| |
| } |
|
|
| if __name__ == "__main__": |
| parser = TrlParser((GRPOScriptArguments, MY_GRPOConfig, ModelConfig)) |
| script_args, training_args, model_args = parser.parse_args_and_config() |
| main(script_args, training_args, model_args) |
|
|