File size: 13,712 Bytes
5ef3875 | 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 269 270 | """Measure how good a YCode-LM model is.
Two complementary metrics:
* **Bits per byte (BPB)** on held-out code: how well the model predicts real
code it never saw. Measured per byte rather than per token, so models with
different tokenizers (e.g. v1 vs v2) are directly comparable. Lower is better.
* **Functional correctness (pass@k)**: the model writes solutions to small
programming problems and the code is actually executed against unit tests.
Higher is better.
Generated code runs in a separate Python process with a timeout, in a
temporary directory, in isolated mode. It is still untrusted code: only
evaluate models you trained yourself.
"""
from __future__ import annotations
import math
import re
import subprocess
import sys
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
import torch
Log = Callable[[str], None]
@dataclass(frozen=True)
class Problem:
name: str
prompt: str
tests: str
def _p(name: str, sig: str, task: str, tests: str) -> Problem:
return Problem(name, f"Write a Python function `{sig}` that {task}", tests.strip())
# Small, unambiguous problems in the same phrasing the model was tuned on.
PROBLEMS: tuple[Problem, ...] = (
_p("add", "add(a, b)", "returns the sum of a and b.", "assert add(2, 3) == 5\nassert add(-1, 1) == 0"),
_p("subtract", "subtract(a, b)", "returns a minus b.", "assert subtract(5, 3) == 2\nassert subtract(0, 4) == -4"),
_p("is_even", "is_even(n)", "returns True if n is even, otherwise False.",
"assert is_even(4) is True\nassert is_even(7) is False"),
_p("square", "square(x)", "returns x squared.", "assert square(3) == 9\nassert square(-2) == 4"),
_p("maximum", "maximum(items)", "returns the largest item in a list.",
"assert maximum([3, 9, 2]) == 9\nassert maximum([-5, -1]) == -1"),
_p("minimum", "minimum(items)", "returns the smallest item in a list.",
"assert minimum([3, 9, 2]) == 2\nassert minimum([-5, -1]) == -5"),
_p("total", "total(numbers)", "returns the sum of a list of numbers.",
"assert total([1, 2, 3]) == 6\nassert total([]) == 0"),
_p("average", "average(numbers)", "returns the average of a non-empty list of numbers.",
"assert average([1, 2, 3]) == 2\nassert average([4]) == 4"),
_p("reverse_string", "reverse_string(s)", "returns the string s reversed.",
"assert reverse_string('abc') == 'cba'\nassert reverse_string('') == ''"),
_p("is_palindrome", "is_palindrome(s)", "returns True if the string s reads the same forwards and backwards.",
"assert is_palindrome('racecar')\nassert not is_palindrome('abc')"),
_p("factorial", "factorial(n)", "returns the factorial of n.",
"assert factorial(0) == 1\nassert factorial(5) == 120"),
_p("fibonacci", "fibonacci(n)", "returns the n-th Fibonacci number, with fibonacci(0) == 0 and fibonacci(1) == 1.",
"assert fibonacci(0) == 0\nassert fibonacci(1) == 1\nassert fibonacci(10) == 55"),
_p("count_vowels", "count_vowels(s)", "returns the number of vowels (a, e, i, o, u) in the string s.",
"assert count_vowels('hello') == 2\nassert count_vowels('xyz') == 0"),
_p("is_prime", "is_prime(n)", "returns True if n is a prime number.",
"assert is_prime(7)\nassert not is_prime(8)\nassert not is_prime(1)\nassert is_prime(2)"),
_p("gcd", "gcd(a, b)", "returns the greatest common divisor of a and b.",
"assert gcd(12, 18) == 6\nassert gcd(7, 5) == 1"),
_p("celsius_to_fahrenheit", "celsius_to_fahrenheit(c)", "converts a temperature from Celsius to Fahrenheit.",
"assert celsius_to_fahrenheit(0) == 32\nassert celsius_to_fahrenheit(100) == 212"),
_p("filter_even", "filter_even(numbers)", "returns a list of the even numbers in numbers.",
"assert filter_even([1, 2, 3, 4]) == [2, 4]\nassert filter_even([]) == []"),
_p("unique", "unique(items)", "returns the items with duplicates removed, keeping the original order.",
"assert unique([1, 2, 1, 3, 2]) == [1, 2, 3]"),
_p("count_words", "count_words(text)", "returns the number of words in text, split on whitespace.",
"assert count_words('a bc d') == 3\nassert count_words('') == 0"),
_p("flatten", "flatten(lists)", "flattens a list of lists into a single list.",
"assert flatten([[1, 2], [3], []]) == [1, 2, 3]"),
_p("clamp", "clamp(x, low, high)", "returns x limited to the range from low to high.",
"assert clamp(5, 0, 3) == 3\nassert clamp(-1, 0, 3) == 0\nassert clamp(2, 0, 3) == 2"),
_p("char_count", "char_count(s, ch)", "returns how many times the character ch occurs in s.",
"assert char_count('banana', 'a') == 3\nassert char_count('abc', 'z') == 0"),
_p("merge_dicts", "merge_dicts(a, b)", "returns a new dict with the keys of a and b, b winning on conflicts.",
"assert merge_dicts({'x': 1}, {'x': 2, 'y': 3}) == {'x': 2, 'y': 3}\n"
"assert merge_dicts({'a': 1}, {'b': 2}) == {'a': 1, 'b': 2}"),
_p("first_word", "first_word(text)", "returns the first word of text.",
"assert first_word('hello world') == 'hello'"),
_p("to_upper", "to_upper(s)", "returns s converted to upper case.", "assert to_upper('abC') == 'ABC'"),
_p("is_sorted", "is_sorted(items)", "returns True if the list is sorted in ascending order.",
"assert is_sorted([1, 2, 2, 5])\nassert not is_sorted([3, 1])"),
_p("second_largest", "second_largest(numbers)", "returns the second largest distinct number in the list.",
"assert second_largest([4, 1, 9, 9, 7]) == 7"),
_p("binary_search", "binary_search(items, target)",
"returns the index of target in the sorted list items, or -1 if it is not present.",
"assert binary_search([1, 3, 5, 7], 5) == 2\nassert binary_search([1, 3, 5, 7], 4) == -1"),
_p("is_anagram", "is_anagram(a, b)", "returns True if the strings a and b are anagrams of each other.",
"assert is_anagram('listen', 'silent')\nassert not is_anagram('ab', 'abc')"),
_p("digit_sum", "digit_sum(n)", "returns the sum of the decimal digits of the non-negative integer n.",
"assert digit_sum(1234) == 10\nassert digit_sum(0) == 0"),
)
def _bug(name: str, doc: str, buggy_body: str) -> tuple[str, str]:
return name, f'def {name}:\n """{doc}"""\n{buggy_body}'
# One realistic bug each (operator swap, off-by-one, flipped comparison, wrong constant).
# The fixed code must pass the matching problem's tests.
BUGGY: tuple[tuple[str, str], ...] = (
_bug("add(a, b)", "Return the sum of a and b.", " return a - b"),
_bug("subtract(a, b)", "Return a minus b.", " return a + b"),
_bug("is_even(n)", "Return True if n is even, otherwise False.", " return n % 2 == 1"),
_bug("square(x)", "Return x squared.", " return x * 2"),
_bug("total(numbers)", "Return the sum of a list of numbers.",
" result = 1\n for x in numbers:\n result += x\n return result"),
_bug("factorial(n)", "Return the factorial of n.",
" result = 1\n for i in range(1, n):\n result *= i\n return result"),
_bug("maximum(items)", "Return the largest item in a list.",
" best = items[0]\n for x in items:\n if x < best:\n best = x\n return best"),
_bug("minimum(items)", "Return the smallest item in a list.",
" best = items[0]\n for x in items:\n if x > best:\n best = x\n return best"),
_bug("count_vowels(s)", "Return the number of vowels in the string s.",
" count = 0\n for c in s:\n if c not in 'aeiou':\n count += 1\n return count"),
_bug("filter_even(numbers)", "Return a list of the even numbers in numbers.",
" return [n for n in numbers if n % 2 == 1]"),
_bug("is_sorted(items)", "Return True if the list is sorted in ascending order.",
" for i in range(len(items) - 1):\n if items[i] < items[i + 1]:\n return False\n"
" return True"),
_bug("celsius_to_fahrenheit(c)", "Convert a temperature from Celsius to Fahrenheit.",
" return c * 9 / 5 - 32"),
_bug("char_count(s, ch)", "Return how many times the character ch occurs in s.",
" count = 0\n for c in s:\n if c != ch:\n count += 1\n return count"),
_bug("clamp(x, low, high)", "Return x limited to the range from low to high.",
" if x < low:\n return high\n if x > high:\n return high\n return x"),
_bug("is_palindrome(s)", "Return True if the string s reads the same forwards and backwards.",
" return s != s[::-1]"),
)
_ALL_TESTS = {p.name: p.tests for p in PROBLEMS}
BUGFIX_PROMPT = "This function has a bug. Find and fix it:\n```python\n{code}\n```"
def bugfix_eval(lm, *, max_new_tokens: int = 200, log: Log | None = None) -> tuple[float, list[str]]:
"""fix@1: share of buggy functions whose greedy "fixed" version passes the tests."""
tests = _ALL_TESTS
fixed: list[str] = []
for signature, code in BUGGY:
name = signature.split("(")[0]
answer = lm.chat([("user", BUGFIX_PROMPT.format(code=code))], max_new_tokens=max_new_tokens, temperature=0)
ok = run_tests(extract_code(answer), tests[name])
if ok:
fixed.append(name)
if log:
log(f" {'FIXED' if ok else 'fail '} {name}")
return len(fixed) / len(BUGGY), fixed
_CODE_BLOCK = re.compile(r"```(?:python|py)?\s*\n(.*?)(?:```|$)", re.DOTALL)
def extract_code(answer: str) -> str:
"""The first fenced code block, or the answer from its first `def` onwards."""
match = _CODE_BLOCK.search(answer)
if match:
return match.group(1).strip()
idx = answer.find("def ")
return answer[idx:].strip() if idx >= 0 else answer.strip()
def run_tests(code: str, tests: str, timeout: float = 5.0) -> bool:
program = f"{code}\n\n{tests}\n"
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "candidate.py"
path.write_text(program, encoding="utf-8")
try:
proc = subprocess.run([sys.executable, "-I", str(path)], cwd=tmp, capture_output=True,
timeout=timeout, stdin=subprocess.DEVNULL)
except subprocess.TimeoutExpired:
return False
return proc.returncode == 0
def pass_at_k(n: int, c: int, k: int) -> float:
"""Unbiased pass@k estimator (Chen et al., 2021)."""
if n - c < k:
return 1.0
return 1.0 - math.prod((n - c - i) / (n - i) for i in range(k))
@dataclass
class FunctionalResult:
pass_at_1: float
pass_at_k: float | None
k: int
solved: list[str] = field(default_factory=list)
samples: dict[str, list[str]] = field(default_factory=dict)
def functional_eval(lm, *, samples: int = 1, temperature: float = 0.8, max_new_tokens: int = 200,
problems: tuple[Problem, ...] = PROBLEMS, seed: int = 0, log: Log | None = None
) -> FunctionalResult:
"""pass@1 from a greedy answer; pass@k from ``samples`` sampled answers when samples > 1."""
torch.manual_seed(seed)
greedy_passes = 0
per_problem: list[tuple[int, int]] = []
solved: list[str] = []
record: dict[str, list[str]] = {}
for prob in problems:
answer = lm.chat([("user", prob.prompt)], max_new_tokens=max_new_tokens, temperature=0)
ok = run_tests(extract_code(answer), prob.tests)
greedy_passes += ok
record[prob.name] = [answer]
if ok:
solved.append(prob.name)
if samples > 1:
correct = 0
for _ in range(samples):
sampled = lm.chat([("user", prob.prompt)], max_new_tokens=max_new_tokens, temperature=temperature)
correct += run_tests(extract_code(sampled), prob.tests)
per_problem.append((samples, correct))
if log:
log(f" {'PASS' if ok else 'fail'} {prob.name}")
k = min(samples, 5) if samples > 1 else 1
pk = (sum(pass_at_k(n, c, k) for n, c in per_problem) / len(per_problem)) if per_problem else None
return FunctionalResult(greedy_passes / len(problems), pk, k, solved, record)
@torch.no_grad()
def bits_per_byte(model, tokenizer, texts: list[str], *, device: str = "cpu", max_tokens: int = 200_000) -> float:
"""Average bits per UTF-8 byte the model needs to encode ``texts``."""
model.eval()
block = model.cfg.block_size
total_nll = 0.0
total_bytes = 0
budget = max_tokens
for text in texts:
ids = tokenizer.encode(text, allow_special=False)
for start in range(0, len(ids) - 1, block):
chunk = ids[start: start + block + 1]
if len(chunk) < 2 or budget <= 0:
continue
x = torch.tensor([chunk[:-1]], device=device)
y = torch.tensor([chunk[1:]], device=device)
logits, _ = model(x, y)
nll = torch.nn.functional.cross_entropy(logits[0].float(), y[0], reduction="sum").item()
total_nll += nll
total_bytes += len(tokenizer.decode(chunk[1:]).encode("utf-8"))
budget -= len(chunk) - 1
if budget <= 0:
break
if total_bytes == 0:
raise ValueError("no evaluation text")
return total_nll / math.log(2) / total_bytes
def load_heldout_texts(sources: list[Path], max_chars: int = 600_000) -> list[str]:
from ycode.lm.data import read_code_files
texts, size = [], 0
for _, text in read_code_files(sources):
texts.append(text)
size += len(text)
if size >= max_chars:
break
return texts
|