GSM8K › Code Implementation: Evaluating Models on GSM8K
Language AI HandbookAll chapters GSM8K › Code Implementation: Evaluating Models on GSM8KOne section · Sections in this chapter8 / 13 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Let's implement a complete evaluation pipeline for GSM8K. We'll load the dataset, implement chain-of-thought prompting, and calculate accuracy metrics.
import subprocess
subprocess.run(["uv", "pip", "install", "datasets"], check=True)import random
from datasets import load_dataset
# Set seed for reproducibility
random.seed(42)
# Load GSM8K dataset from HuggingFace
dataset = load_dataset("openai/gsm8k", "main")
train_data = dataset["train"]
test_data = dataset["test"]Training examples: 7473 Test examples: 1319
The dataset contains 7,500 training examples and 1,000 test examples, exactly matching the original paper's split. The training set gives a rich pool of human-annotated reasoning chains for constructing few-shot prompts and for fine-tuning experiments. The test set is the standardized evaluation set. This allows real comparisons across different approaches and model configurations.
Examining Problem Structure
Let's examine a specific example to understand the format in detail.
# Examine a sample problem
sample = test_data[0]Question: Janet’s ducks lay 16 eggs per day. She eats three for breakfast every morning and bakes muffins for her friends every day with four. She sells the remainder at the farmers' market daily for $2 per fresh duck egg. How much in dollars does she make every day at the farmers' market? Answer (with reasoning): Janet sells 16 - 3 - 4 = <<16-3-4=9>>9 duck eggs a day. She makes 9 * 2 = $<<9*2=18>>18 every day at the farmer’s market. #### 18
The example shows the detailed reasoning format with the final answer clearly delimited after ####. Each example contains the natural language question and a step-by-step answer where every arithmetic operation is spelled out explicitly before the final result is stated. This level of annotation detail makes GSM8K useful as an evaluation dataset and as a training resource for teaching models to reason explicitly.
Implementing Answer Extraction
We need reliable functions to extract final answers from model outputs and ground truth labels. The extraction logic must handle several variations: the #### delimiter format used in the training data, plain numeric answers at the end of responses, and formatted numbers with commas or decimal points.
import re
def extract_answer_from_text(text: str) -> str:
"""
Extract the final numeric answer from GSM8K formatted text.
Looks for #### delimiter first, then falls back to final number.
"""
# First try to find #### delimiter
if "####" in text:
match = re.search(r"####\s*(-?\d+)", text)
if match:
return match.group(1)
# Fallback: find last number in text
numbers = re.findall(r"-?\d+", text.replace(",", ""))
if numbers:
return numbers[-1]
return ""
def normalize_answer(answer: str) -> str:
"""Normalize answer by removing commas and whitespace."""
return answer.replace(",", "").strip()
# Test extraction on our sample
ground_truth = sample["answer"]
extracted = extract_answer_from_text(ground_truth)Ground truth text (last 50 chars): ...2=18>>18 every day at the farmer’s market. #### 18 Extracted answer: 18
The extraction correctly isolates the numeric answer after the #### delimiter. The regex pattern r"####\s*(-?\d+)" matches the delimiter followed by optional whitespace and a potentially negative integer. The fallback to the last number in the text handles model outputs that do not follow the #### convention, which occurs when models are not explicitly instructed to use this delimiter format.
Creating Chain-of-Thought Prompts
We'll construct few-shot prompts with chain-of-thought exemplars drawn from the training set. The prompt structure places several solved examples before the target question, giving the model a clear demonstration of the expected reasoning format.
import random
from typing import Dict, List
def create_cot_prompt(
examples: List[Dict], test_question: str, num_shots: int = 5
) -> str:
"""
Create a chain-of-thought prompt with few-shot examples.
Each example shows the question and full step-by-step answer.
"""
selected_examples = random.sample(examples, num_shots)
prompt_parts = []
for ex in selected_examples:
prompt_parts.append(f"Q: {ex['question']}")
prompt_parts.append(f"A: {ex['answer']}\n")
prompt_parts.append(f"Q: {test_question}")
prompt_parts.append("A:")
return "\n".join(prompt_parts)
# Create a CoT prompt for our sample question
cot_prompt = create_cot_prompt(
[train_data[i] for i in range(10)], # Use first 10 for the pool
sample["question"],
num_shots=3,
)Chain-of-Thought Prompt: ================================================== Q: Weng earns $12 an hour for babysitting. Yesterday, she just did 50 minutes of babysitting. How much did she earn? A: Weng earns 12/60 = $<<12/60=0.2>>0.2 per minute. Working 50 minutes, she earned 0.2 x 50 = $<<0.2*50=10>>10. #### 10 Q: Natalia sold clips to 48 of her friends in April, and then she sold half as many clips in May. How many clips did Natalia sell altogether in April and May? A: Natalia sold 48/2 = <<48/2=24>>24 clips in May. Natalia sold 48+24 = <<48+24=72>>72 clips altogether in April and May. #### 72 Q: James writes a 3-page letter to 2 different friends twice a week. How many pages does he write a year? A: He writes each friend 3*2=<<3*2=6>>6 pages a week So he writes 6*2=<<6*2=12>>12 pages every week That means he writes 12*52=<<12*52=624>>624 pages a year #### 624 Q: Janet’s ducks lay 16 eggs per day. She eats three for breakfast every morning and bakes muffins for her friends every day with four. She sells the remainder at the farmers' market daily for $2 pe...
The prompt now contains three worked examples showing the step-by-step format, followed by the target question. This structure guides the model to generate similar reasoning traces rather than jumping directly to the final answer. Each example in the few-shot set is a stylistic demonstration: the model observes how intermediate arithmetic is spelled out, how quantities are tracked across steps, and how the final answer is delimited.
The selection of few-shot examples matters more than it might initially appear. Examples that are semantically similar to the test question can serve as stronger retrieval cues, helping the model recognize needed problem structures. However, random sampling from the training set typically works well because GSM8K problems share common linguistic patterns regardless of their specific content.
Simulating Model Evaluation
Since we don't have a live large language model API in this environment, we'll simulate the evaluation logic and show the accuracy calculation with a mock function representing model outputs.
def simulate_model_response(question: str, ground_truth_answer: str) -> str:
"""
Simulate a model response for demonstration purposes.
In practice, this would call an LLM API.
Produces correct answers 70% of the time with proper chain-of-thought,
and off-by-small arithmetic errors the remaining 30%.
"""
extracted_gt = extract_answer_from_text(ground_truth_answer)
# 70% chance of correct answer with proper CoT
if random.random() < 0.7:
reasoning = ground_truth_answer.split("####")[0].strip()
return f"{reasoning}\n#### {extracted_gt}"
else:
# Simulate arithmetic error: answer is off by a small amount
wrong_answer = str(int(extracted_gt) + random.choice([-1, 1, -2, 2]))
reasoning = ground_truth_answer.split("####")[0].strip()
return f"{reasoning}\n#### {wrong_answer}"
def evaluate_gsm8k_sample(
question: str, ground_truth: str, model_response: str
) -> tuple[bool, str, str]:
"""
Evaluate a single GSM8K sample.
Returns: (is_correct, predicted_answer, ground_truth_answer)
"""
pred = normalize_answer(extract_answer_from_text(model_response))
true = normalize_answer(extract_answer_from_text(ground_truth))
return pred == true, pred, true
# Test evaluation on our sample
mock_response = simulate_model_response(sample["question"], sample["answer"])
is_correct, pred, true = evaluate_gsm8k_sample(
sample["question"], sample["answer"], mock_response
)Model response excerpt: Janet sells 16 - 3 - 4 = <<16-3-4=9>>9 duck eggs a day. She makes 9 * 2 = $<<9*2=18>>18 every day at the farmer’s market. #### 18... Predicted: 18 Ground truth: 18 Correct: True
The single-sample evaluation shows our exact-match validation logic in action. By extracting the final numeric answer from both the model's simulated response and the ground truth, then comparing them after normalization, we verify that the evaluation pipeline correctly identifies correct and incorrect predictions. The 70% accuracy of our simulated model shows realistic performance of chain-of-thought prompting on moderately sized language models.
Batch Evaluation and Metrics
Now let's implement batch evaluation to calculate overall accuracy across multiple samples.
def evaluate_gsm8k_batch(
test_samples: List[Dict],
train_pool: List[Dict],
num_shots: int = 5,
sample_size: int = 50,
) -> Dict:
"""
Evaluate a batch of GSM8K problems with chain-of-thought prompting.
Returns a dict with total, correct, accuracy, and per-sample predictions.
"""
results = {"total": 0, "correct": 0, "predictions": []}
test_subset = test_samples[:sample_size]
for item in test_subset:
# Create prompt (in practice, would call LLM here)
prompt = create_cot_prompt(train_pool, item["question"], num_shots)
# Simulate model response
response = simulate_model_response(item["question"], item["answer"])
# Evaluate
is_correct, pred, true = evaluate_gsm8k_sample(
item["question"], item["answer"], response
)
results["total"] += 1
if is_correct:
results["correct"] += 1
results["predictions"].append(
{
"question": item["question"][:100] + "...",
"predicted": pred,
"ground_truth": true,
"correct": is_correct,
}
)
results["accuracy"] = (
results["correct"] / results["total"] if results["total"] > 0 else 0
)
return results
# Run evaluation on a small subset
train_pool = [train_data[i] for i in range(100)]
test_list = list(test_data)
evaluation_results = evaluate_gsm8k_batch(
test_list, train_pool, num_shots=3, sample_size=20
)Total evaluated: 20 Correct: 15 Accuracy: 75.00%
The evaluation reaches approximately 70% accuracy on this sample, which aligns with expected performance for chain-of-thought prompting on moderately sized language models. This accuracy level indicates that the simulated model correctly solves most arithmetic reasoning problems but still struggles with approximately 30% of cases. In practice, those failures split roughly between arithmetic errors (off-by-one or off-by-small mistakes in multi-digit arithmetic) and problem structure errors (applying the wrong operation despite correct parsing of the problem text).
Visualizing Evaluation Results
Let's visualize the breakdown of correct and incorrect predictions.


The visualization shows the exact match evaluation criterion in its starkest form. Even when models generate reasonable reasoning chains, only predictions matching the ground truth exactly count as correct. A predicted answer of 17 when the correct answer is 18 contributes nothing to the accuracy score, which is why error analysis focusing on the magnitude of arithmetic mistakes is important for understanding model limitations beyond the headline accuracy number.
Self-Consistency Implementation
Let's implement self-consistency decoding, where we sample multiple reasoning paths and take the majority vote.
from collections import Counter
def self_consistency_evaluate(
question: str,
ground_truth: str,
train_pool: List[Dict],
num_samples: int = 5,
num_shots: int = 3,
) -> tuple[str, int]:
"""
Evaluate using self-consistency: sample multiple CoT paths and majority vote.
Returns: (final_answer, agreement_count)
The agreement count indicates how many of the num_samples paths agreed
on the returned final_answer.
"""
answers = []
prompt = create_cot_prompt(train_pool, question, num_shots)
# Simulate multiple independent samples with different random seeds
for i in range(num_samples):
random.seed(42 + i) # Different seed for each sample
response = simulate_model_response(question, ground_truth)
answer = normalize_answer(extract_answer_from_text(response))
if answer:
answers.append(answer)
# Majority vote: return the most common answer and its count
if not answers:
return "", 0
answer_counts = Counter(answers)
final_answer, count = answer_counts.most_common(1)[0]
return final_answer, count
# Test self-consistency on a sample
test_item = test_list[1]
sc_answer, agreement = self_consistency_evaluate(
test_item["question"], test_item["answer"], train_pool, num_samples=5
)
true_answer = normalize_answer(extract_answer_from_text(test_item["answer"]))Question: A robe takes 2 bolts of blue fiber and half that much white fiber. How many bolts in total does it ... Self-consensus answer: 3 (agreement: 4/5) Ground truth: 3 Correct: True
The output shows self-consistency in action. The agreement count tells us how many of the five sampled reasoning paths converged on the returned answer. High agreement (4 or 5 out of 5) indicates strong confidence in the result, while low agreement (2 or 3 out of 5) signals uncertainty. Some research teams use this agreement score as a proxy for model confidence, flagging low-agreement predictions for human review in high-stakes applications. Self-consistency typically improves accuracy by 5-15% on GSM8K by filtering out sporadic arithmetic errors that occur in individual reasoning chains.