File size: 9,761 Bytes
ce7c1f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
"""T5-small fine-tuning and inference for one-sentence tiny summaries.

Training uses `short_ref -> tiny_ref` pairs. At inference, the pipeline feeds
the generated short summary to the fine-tuned T5 checkpoint, which keeps tiny
generation cheap and less exposed to the full legal source.
"""

from __future__ import annotations

import argparse
import inspect
from dataclasses import dataclass
from pathlib import Path

import pandas as pd

from src.utils import MODELS_DIR, RESULTS_DIR, get_logger, set_seed

logger = get_logger(__name__)

DEFAULT_MODEL_DIR = MODELS_DIR / "t5_tiny_summarizer"
DEFAULT_BASE_MODEL = "t5-small"


@dataclass(frozen=True)
class TinyTrainConfig:
    input_path: Path = Path("data/multilexsum_clean.parquet")
    output_dir: Path = DEFAULT_MODEL_DIR
    source_column: str = "short_ref"
    target_column: str = "tiny_ref"
    base_model: str = DEFAULT_BASE_MODEL
    epochs: int = 3
    learning_rate: float = 5e-5
    batch_size: int = 4
    max_source_length: int = 256
    max_target_length: int = 48
    max_train: int | None = None
    max_val: int | None = None
    seed: int = 42


class TinyT5Summarizer:
    """Inference wrapper for the fine-tuned tiny-summary model."""

    def __init__(
        self,
        model_dir: str | Path = DEFAULT_MODEL_DIR,
        fallback_model: str = DEFAULT_BASE_MODEL,
        device: str | None = None,
        max_source_length: int = 256,
        max_new_tokens: int = 40,
    ) -> None:
        try:
            import torch
            from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
        except ImportError as exc:
            raise ImportError("Install transformers, torch, and sentencepiece before tiny inference.") from exc

        model_path = Path(model_dir)
        model_name = str(model_path) if (model_path / "config.json").exists() else fallback_model
        self.tokenizer = AutoTokenizer.from_pretrained(model_name)
        self.model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
        self.torch = torch
        self.max_source_length = max_source_length
        self.max_new_tokens = max_new_tokens

        if device is None:
            if torch.cuda.is_available():
                device = "cuda"
            elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
                device = "mps"
            else:
                device = "cpu"
        self.device = device
        self.model.to(device)
        self.model.eval()
        logger.info("Loaded tiny summarizer %s on %s", model_name, device)

    def summarize(self, short_summary: str) -> str:
        prompt = f"summarize: {short_summary.strip()}"
        inputs = self.tokenizer(
            prompt,
            return_tensors="pt",
            truncation=True,
            max_length=self.max_source_length,
        )
        inputs = {k: v.to(self.device) for k, v in inputs.items()}
        with self.torch.no_grad():
            output_ids = self.model.generate(
                **inputs,
                max_new_tokens=self.max_new_tokens,
                num_beams=4,
                no_repeat_ngram_size=2,
                length_penalty=0.8,
            )
        return self.tokenizer.decode(output_ids[0], skip_special_tokens=True).strip()


def _prepare_dataset(df: pd.DataFrame, source_column: str, target_column: str, max_rows: int | None):
    from datasets import Dataset

    work = df[[source_column, target_column]].dropna().rename(
        columns={source_column: "source", target_column: "target"}
    )
    if max_rows is not None:
        work = work.head(max_rows)
    return Dataset.from_pandas(work.reset_index(drop=True))


def _training_args_kwargs(config: TinyTrainConfig) -> dict:
    from transformers import Seq2SeqTrainingArguments

    params = inspect.signature(Seq2SeqTrainingArguments.__init__).parameters
    kwargs = {
        "output_dir": str(config.output_dir),
        "learning_rate": config.learning_rate,
        "per_device_train_batch_size": config.batch_size,
        "per_device_eval_batch_size": config.batch_size,
        "num_train_epochs": config.epochs,
        "weight_decay": 0.01,
        "predict_with_generate": True,
        "logging_steps": 25,
        "save_strategy": "epoch",
        "save_total_limit": 2,
        "report_to": [],
        "seed": config.seed,
    }
    if "eval_strategy" in params:
        kwargs["eval_strategy"] = "epoch"
    else:
        kwargs["evaluation_strategy"] = "epoch"
    return kwargs


def train_tiny_model(config: TinyTrainConfig) -> pd.DataFrame:
    """Fine-tune T5-small and write the checkpoint plus validation-loss logs."""

    try:
        from transformers import (
            AutoModelForSeq2SeqLM,
            AutoTokenizer,
            DataCollatorForSeq2Seq,
            Seq2SeqTrainer,
            Seq2SeqTrainingArguments,
        )
    except ImportError as exc:
        raise ImportError("Install transformers, torch, datasets, and sentencepiece before training.") from exc

    set_seed(config.seed)
    df = pd.read_parquet(config.input_path)
    train_df = df[df["split"] == "train"]
    val_df = df[df["split"] == "val"]

    train_ds = _prepare_dataset(train_df, config.source_column, config.target_column, config.max_train)
    val_ds = _prepare_dataset(val_df, config.source_column, config.target_column, config.max_val)

    tokenizer = AutoTokenizer.from_pretrained(config.base_model)
    model = AutoModelForSeq2SeqLM.from_pretrained(config.base_model)

    def preprocess(batch):
        inputs = [f"summarize: {x}" for x in batch["source"]]
        model_inputs = tokenizer(
            inputs,
            max_length=config.max_source_length,
            truncation=True,
        )
        labels = tokenizer(
            text_target=batch["target"],
            max_length=config.max_target_length,
            truncation=True,
        )
        model_inputs["labels"] = labels["input_ids"]
        return model_inputs

    tokenized_train = train_ds.map(preprocess, batched=True, remove_columns=train_ds.column_names)
    tokenized_val = val_ds.map(preprocess, batched=True, remove_columns=val_ds.column_names)

    args = Seq2SeqTrainingArguments(**_training_args_kwargs(config))
    collator = DataCollatorForSeq2Seq(tokenizer=tokenizer, model=model)
    trainer_kwargs = {
        "model": model,
        "args": args,
        "train_dataset": tokenized_train,
        "eval_dataset": tokenized_val,
        "data_collator": collator,
    }
    trainer_params = inspect.signature(Seq2SeqTrainer.__init__).parameters
    if "tokenizer" in trainer_params:
        trainer_kwargs["tokenizer"] = tokenizer
    elif "processing_class" in trainer_params:
        trainer_kwargs["processing_class"] = tokenizer
    trainer = Seq2SeqTrainer(**trainer_kwargs)

    trainer.train()
    trainer.save_model(str(config.output_dir))
    tokenizer.save_pretrained(str(config.output_dir))
    logger.info("Saved fine-tuned T5 checkpoint -> %s", config.output_dir)

    history = pd.DataFrame(trainer.state.log_history)
    results_path = RESULTS_DIR / "t5_tiny_val_loss.csv"
    RESULTS_DIR.mkdir(parents=True, exist_ok=True)
    history.to_csv(results_path, index=False)
    logger.info("Wrote training log -> %s", results_path)

    if "eval_loss" in history.columns:
        import matplotlib.pyplot as plt

        curve = history.dropna(subset=["eval_loss"])
        if not curve.empty:
            plt.figure(figsize=(6, 4))
            plt.plot(curve["epoch"], curve["eval_loss"], marker="o")
            plt.xlabel("Epoch")
            plt.ylabel("Validation loss")
            plt.title("T5-small tiny-summary fine-tune")
            plt.tight_layout()
            plot_path = RESULTS_DIR / "t5_tiny_val_loss.png"
            plt.savefig(plot_path, dpi=160)
            plt.close()
            logger.info("Wrote validation-loss curve -> %s", plot_path)

    return history


def build_arg_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="Fine-tune or run T5-small tiny summarization.")
    parser.add_argument("--train", action="store_true", help="Fine-tune the model.")
    parser.add_argument("--input", default="data/multilexsum_clean.parquet")
    parser.add_argument("--output-dir", default=str(DEFAULT_MODEL_DIR))
    parser.add_argument("--source-column", default="short_ref")
    parser.add_argument("--target-column", default="tiny_ref")
    parser.add_argument("--base-model", default=DEFAULT_BASE_MODEL)
    parser.add_argument("--epochs", type=int, default=3)
    parser.add_argument("--lr", type=float, default=5e-5)
    parser.add_argument("--batch-size", type=int, default=4)
    parser.add_argument("--max-train", type=int)
    parser.add_argument("--max-val", type=int)
    parser.add_argument("--text", help="Short summary to compress at inference time.")
    parser.add_argument("--device", choices=["cpu", "cuda", "mps"])
    return parser


def main() -> None:
    args = build_arg_parser().parse_args()
    if args.train:
        config = TinyTrainConfig(
            input_path=Path(args.input),
            output_dir=Path(args.output_dir),
            source_column=args.source_column,
            target_column=args.target_column,
            base_model=args.base_model,
            epochs=args.epochs,
            learning_rate=args.lr,
            batch_size=args.batch_size,
            max_train=args.max_train,
            max_val=args.max_val,
        )
        train_tiny_model(config)
        return

    if not args.text:
        raise SystemExit("Provide --train or --text.")
    summarizer = TinyT5Summarizer(model_dir=args.output_dir, device=args.device)
    print(summarizer.summarize(args.text))


if __name__ == "__main__":
    main()