File size: 8,466 Bytes
85a749e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
scripts/evaluate.py

Evaluation script using lm-evaluation-harness (lm_eval==0.4.3).

Run:
    # Evaluate a merged model (no LoRA adapter):
    python scripts/evaluate.py --model-path ./final_model --tasks gsm8k mmlu

    # Evaluate a LoRA checkpoint on top of base model:
    python scripts/evaluate.py \
        --model-path microsoft/Phi-3-mini-4k-instruct \
        --peft-path ./checkpoints/grpo \
        --tasks gsm8k mmlu strategy_qa

    # Run full ablation across all checkpoints:
    python scripts/evaluate.py --ablation

HALLUCINATION NOTES:
  - lm_eval.simple_evaluate() signature verified for lm_eval==0.4.3.
    Key params: model, model_args, tasks, num_fewshot, batch_size, limit, log_samples.
  - Task names in lm_eval 0.4.3:
      GSM8K:      "gsm8k"
      MMLU:       "mmlu" (evaluates all MMLU subsets, averages)
      StrategyQA: "strategy_qa"
    These names were verified from lm_eval 0.4.3 task registry. If a task name
    fails, run: lm_eval --tasks list | grep -i <name>
  - MMLU num_fewshot=5 is standard practice from the original MMLU paper.
  - model_args string format for PEFT: "pretrained=<base>,peft=<adapter_path>"
    This is the documented lm_eval HF model_args format.
  - 'limit' parameter accepts int (number of examples) or float (fraction).
"""

from __future__ import annotations

import argparse
import json
import logging
import os
import sys
from pathlib import Path

import pandas as pd

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)


TASK_FEWSHOT = {
    "gsm8k": 0,
    "mmlu": 5,
    "strategy_qa": 0,
}

# Metric keys to extract from lm_eval results
# HALLUCINATION NOTE: these metric key names are from lm_eval 0.4.3 output format.
# GSM8K uses 'exact_match,strict-match' or 'exact_match,flexible-extract'.
# MMLU uses 'acc,none'. StrategyQA uses 'acc,none'.
# If keys differ, print(results['results']) to inspect.
TASK_METRIC_KEY = {
    "gsm8k": "exact_match,flexible-extract",
    "mmlu": "acc,none",
    "strategy_qa": "acc,none",
}


def parse_args():
    parser = argparse.ArgumentParser(description="Evaluate SLM checkpoints")
    parser.add_argument("--model-path", default="microsoft/Phi-3-mini-4k-instruct",
                        help="Path to merged model or HF model ID")
    parser.add_argument("--peft-path", default=None,
                        help="Path to LoRA adapter checkpoint (optional)")
    parser.add_argument("--tasks", nargs="+",
                        default=["gsm8k", "mmlu", "strategy_qa"])
    parser.add_argument("--batch-size", type=int, default=4)
    parser.add_argument("--limit", type=int, default=None,
                        help="Limit number of examples per task (for quick testing)")
    parser.add_argument("--output-dir", default="results")
    parser.add_argument("--run-name", default="eval",
                        help="Name for the output JSON file")
    parser.add_argument("--ablation", action="store_true",
                        help="Run ablation across all standard checkpoints")
    parser.add_argument("--device", default="cuda",
                        help="Device: 'cuda', 'cuda:0', 'cpu'")
    return parser.parse_args()


def build_model_args(model_path: str, peft_path: str | None = None) -> str:
    """Build the model_args string for lm_eval simple_evaluate."""
    args = f"pretrained={model_path}"
    if peft_path:
        args += f",peft={peft_path}"
    args += ",trust_remote_code=True"
    return args


def run_evaluation(
    model_path: str,
    peft_path: str | None,
    tasks: list[str],
    batch_size: int = 4,
    limit: int | None = None,
    device: str = "cuda",
) -> dict:
    """
    Run lm_eval evaluation and return results dict.

    Returns a flat dict: {task_name: score, ...}
    """
    from lm_eval import simple_evaluate

    model_args = build_model_args(model_path, peft_path)
    logger.info("Evaluating: %s", model_args)
    logger.info("Tasks: %s", tasks)

    # num_fewshot: pass per-task as a list aligned with tasks
    num_fewshot = [TASK_FEWSHOT.get(t, 0) for t in tasks]

    raw_results = simple_evaluate(
        model="hf",
        model_args=model_args,
        tasks=tasks,
        num_fewshot=num_fewshot,
        batch_size=batch_size,
        limit=limit,
        log_samples=False,
        device=device,
    )

    # Extract primary metric per task
    scores = {}
    for task in tasks:
        if task not in raw_results["results"]:
            logger.warning("Task '%s' not found in results.", task)
            continue
        task_results = raw_results["results"][task]
        metric_key = TASK_METRIC_KEY.get(task)
        if metric_key and metric_key in task_results:
            scores[task] = task_results[metric_key]
        else:
            # Fallback: print all available keys so user can find the right one
            logger.warning(
                "Metric key '%s' not found for task '%s'. Available: %s",
                metric_key, task, list(task_results.keys()),
            )
            # Try common fallbacks
            for fallback in ["acc,none", "exact_match,flexible-extract",
                             "exact_match,strict-match"]:
                if fallback in task_results:
                    scores[task] = task_results[fallback]
                    logger.info("Used fallback metric '%s' for task '%s'", fallback, task)
                    break

    return scores, raw_results


def save_results(
    scores: dict,
    raw_results: dict,
    output_dir: str,
    run_name: str,
) -> None:
    os.makedirs(output_dir, exist_ok=True)
    out_path = os.path.join(output_dir, f"{run_name}.json")
    with open(out_path, "w") as f:
        json.dump({"scores": scores, "raw": raw_results["results"]}, f, indent=2)
    logger.info("Results saved to %s", out_path)


def run_ablation(tasks: list[str], batch_size: int, limit: int | None, device: str):
    """
    Run evaluation across all standard checkpoints and print the ablation table.

    Checkpoints expected (adjust paths to match your training output):
      - Baseline:          microsoft/Phi-3-mini-4k-instruct (no peft)
      - SFT:               checkpoints/sft (LoRA on base)
      - GRPO:              checkpoints/grpo (LoRA on base)
      - Curriculum GRPO:   checkpoints/curriculum_grpo (LoRA on base)
    """
    base_model = "microsoft/Phi-3-mini-4k-instruct"
    runs = [
        ("Baseline",          base_model,  None),
        ("SFT",               base_model,  "checkpoints/sft"),
        ("SFT + GRPO",        base_model,  "checkpoints/grpo"),
        ("SFT + Curriculum",  base_model,  "checkpoints/curriculum_grpo"),
    ]

    all_scores = []
    for name, model_path, peft_path in runs:
        if peft_path and not os.path.exists(peft_path):
            logger.warning("Checkpoint not found: %s — skipping %s", peft_path, name)
            continue
        logger.info("Evaluating: %s", name)
        scores, raw = run_evaluation(model_path, peft_path, tasks, batch_size, limit, device)
        scores["run"] = name
        all_scores.append(scores)
        save_results(scores, raw, "results", name.replace(" ", "_").lower())

    if all_scores:
        df = pd.DataFrame(all_scores).set_index("run")
        # Format as percentage
        for col in df.columns:
            df[col] = (df[col] * 100).round(2).astype(str) + "%"
        print("\n" + "="*60)
        print("ABLATION TABLE")
        print("="*60)
        print(df.to_string())
        print("="*60 + "\n")
        df.to_csv("results/ablation_table.csv")
        logger.info("Ablation table saved to results/ablation_table.csv")


def main():
    args = parse_args()

    if args.ablation:
        run_ablation(
            tasks=args.tasks,
            batch_size=args.batch_size,
            limit=args.limit,
            device=args.device,
        )
        return

    scores, raw_results = run_evaluation(
        model_path=args.model_path,
        peft_path=args.peft_path,
        tasks=args.tasks,
        batch_size=args.batch_size,
        limit=args.limit,
        device=args.device,
    )

    print("\n=== SCORES ===")
    for task, score in scores.items():
        print(f"  {task:20s}: {score*100:.2f}%")

    save_results(scores, raw_results, args.output_dir, args.run_name)


if __name__ == "__main__":
    main()