LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 196 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/17
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningTraining Run OperationsReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/31
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingGPU Failure OperationsDistributed Inference Data PlaneServing Benchmark EngineeringA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnComputing FoundationsPython for AI Engineering
⚙️EasyMLOps & Deployment

Python for AI Engineering

Learn Python by building a small evaluator: read JSONL, validate fields, count correct predictions, and test the difference between bad input and a wrong answer.

17 min read
Learning path
Step 3 of 196 in the full curriculum
Docker for Reproducible AINumPy and Tensor Shapes

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

The Docker image from the previous lesson printed a passing evaluation report. Two predictions matched their expected labels; one didn't:

scorer-receipt.txt
1Eval rows: 3 2Exact-match accuracy on tiny fixture: 0.667 (2/3) 3Gate passed. You may commit.

Git stored eval/access_requests.jsonl and scripts/score_access_requests.py. Docker supplied the operating system files and Python runtime, but neither one explains how a line of text becomes 2/3.

That path is ordinary Python: a file becomes rows, rows become counts, and counts become a printed score. Rebuild it with each boundary visible.[1]Reference 1The Python Tutorial.https://docs.python.org/3/tutorial/

Evaluation pipeline flow: Three JSONL rows are normalized and matched. Requests 101 and 103 match (contributing 2 to numerator), while request 102 mismatches (contributing 0 to numerator). All three rows contribute to total rows (3 in denominator). Dual accumulation yields accuracy 2 / 3 = 0.667, passing the exact integer gate 2 * 3 >= 3 * 2.= 3 * 2." />
Follow the dataflow through two accumulators: matches sums valid exact predictions, while total counts every evaluated row. Request 102 adds nothing to the numerator, but it still belongs in the denominator.

Pin a local Python 3.12 project

The image already pins one runtime path. Before changing the scorer, give your checkout a matching interpreter and a separate place for its tools. Otherwise a passing test can depend on whichever Python and packages happen to be first on your PATH.

A virtual environment provides a project-local package directory separate from the operating system's Python. Local work here uses uv's project workflow for that isolation.

Keep the lab on Python 3.12 because that's what the Docker image already pins, not because 3.12 is the newest CPython release.

Confirm uv --version works. If it doesn't, use the official uv installation guide for your operating system. Run these commands from your earlier lab repository, not from an unrelated project. Skip uv init if pyproject.toml already exists:

terminal
1uv python install 3.12 2uv init --bare --python 3.12 3uv python pin 3.12 4uv sync 5uv run python --version

Read the files after running these commands. uv init --bare writes only pyproject.toml; its requires-python field declares compatible Python versions. A 3.12 pin selects that minor release family, not one exact patch release.

uv python pin 3.12 writes .python-version. The first uv sync or uv run creates .venv and uv.lock.[2]Reference 2Locking and Syncinghttps://docs.astral.sh/uv/concepts/projects/sync/

Each path answers a different reproducibility question. Keep the portable inputs in Git and recreate machine-local output:

PathWhat it recordsCommit it?
pyproject.tomlsupported Python range and direct dependenciesyes
.python-versiondefault Python version for this checkoutyes
uv.lockresolved dependency versionsyes
.venv/installed packages for this machineno

uv run uses the project environment without shell activation. If you activate it on macOS or Linux, run source .venv/bin/activate; on Windows PowerShell, run .venv\Scripts\Activate.ps1. Activation changes which python your shell finds.

uv sync creates the environment and resolves its dependencies. Activation is a shell convenience, not dependency management.

Python also ships venv. In a project that doesn't use uv, python3.12 -m venv .venv creates the same kind of isolation. Pick one environment workflow per project so package state stays understandable. Keep Docker's requirements.txt; don't replace the image install path used by the earlier scorer.

Why shouldn't .venv/ be committed even though uv.lock should be?

Answer

The lockfile is portable input that records resolved dependencies. .venv/ contains machine-specific installed files and can be recreated from the project files with uv sync.

One row is ordinary Python data

Start with request 102, the row the scorer should mark wrong. Before writing a loop, predict what one row must contain and what the comparison should return:

request-102.json
1{"prompt": "Access request 102 status?", "expected": "blocked", "prediction": "escalated"}

After parsing, this JSON object becomes a Python dictionary. A dictionary maps keys such as "expected" to values such as "blocked", so row["expected"] retrieves one named field. Assigning the mapping to row gives later operations a stable name.

The next cell reads both labels, normalizes outer whitespace and case, then stores the comparison as a Boolean. Its output lets you check the type and the decision before any batch logic appears.

inspect-one-row.py
1row = { 2 "prompt": "Access request 102 status?", 3 "expected": "blocked", 4 "prediction": "escalated", 5} 6 7expected = row["expected"].strip().lower() 8prediction = row["prediction"].strip().lower() 9is_correct = prediction == expected 10 11print("prompt:", row["prompt"]) 12print("expected type:", type(expected).__name__) 13print("is correct:", is_correct)
Output
1prompt: Access request 102 status? 2expected type: str 3is correct: False

Python evaluates the right side of = first, then binds that value to the name on the left. A single = assigns; == compares.

strip() removes whitespace from both ends, and lower() returns a lowercase string. Neither method edits the original string. Strings are immutable: operations return results instead of changing their contents in place.

Here, exact match means equality after these two normalization steps. It's appropriate for the fixture's short status labels. It won't recognize two differently worded explanations as equivalent, and it doesn't remove spaces inside a label.

Built-in values used in the scorer

You don't need every Python type before writing useful code. These eight cover the values moving through this evaluator:

TypeExampleUse in this scorer
str"blocked"prompts, labels, and file paths
int2correct-row and total-row counts
float2 / 3the accuracy score
boolFalseone row's exact-match decision
NoneTypeNonean explicit absence of a value
list[True, False, True]ordered results for all rows
tuple(2, 3, 2 / 3)a fixed group of returned values
dict{"expected": "blocked"}named fields for one row

Python is dynamically typed: a name can be rebound to a value of another type. That flexibility doesn't make types interchangeable. "2" is text, 2 is an integer, and 2.0 is a floating-point number. Convert with int(), float(), or str() only when the data contract says the conversion is valid. A malformed label shouldn't become a value that happens to score.

A list turns one row into a batch

The protected evaluation fixture contains three dictionaries in order. Before running the batch, predict the receipt: two labels match, one differs, and the denominator stays at three.

RequestExpectedPredictionExact match
101approvedapproved1
102blockedescalated0
103restoredrestored1

A list keeps those rows together, and a for loop visits each row once. Indentation marks the statements that belong to the loop. The code records each Boolean before it computes an aggregate, so a surprising score still has a per-row trail.

score-a-list.py
1examples = [ 2 {"prompt": "Access request 101 status?", "expected": "approved", "prediction": "approved"}, 3 {"prompt": "Access request 102 status?", "expected": "blocked", "prediction": "escalated"}, 4 {"prompt": "Access request 103 status?", "expected": "restored", "prediction": "restored"}, 5] 6 7row_results = [] 8 9for position, row in enumerate(examples, start=1): 10 is_correct = row["prediction"].strip().lower() == row["expected"].strip().lower() 11 row_results.append(is_correct) 12 print(position, row["prediction"], is_correct) 13 14matches = sum(row_results) 15total = len(row_results) 16score = matches / total 17 18print("matches:", matches) 19print("score:", f"{score:.3f}")
Output
11 approved True 22 escalated False 33 restored True 4matches: 2 5score: 0.667

enumerate(..., start=1) supplies a readable position with each row, while append() keeps one Boolean for that row.

Then len() counts all results and sum() treats True as 1 and False as 0, producing the correct-row count. An f-string such as f"{score:.3f}" formats the fraction for display after the calculation is done.

Check the arithmetic by hand before trusting the output:

accuracy=correct rowsall rows=23≈0.667\text{accuracy} = \frac{\text{correct rows}}{\text{all rows}} = \frac{2}{3} \approx 0.667accuracy=all rowscorrect rows​=32​≈0.667

If the code prints anything else for these exact three rows, inspect the per-row Booleans first. A mismatch there points to normalization; matching Booleans with a wrong total points to aggregation.

After adding a fourth row whose prediction matches its expected label, what should the score become?

Answer

There are now three correct rows out of four, so the score becomes 3 / 4 = 0.75.

Functions give the comparison a name

The loop works, but comparison, aggregation, and printing are tangled together. Pull the comparison out first. A function packages one operation behind a name: inputs arrive as parameters, and return sends a value back to the caller.

Ask what should stay true if another caller needs the same rule. Both labels should be stripped and lowercased, and the function should return only the Boolean decision. This function owns that normalization rule:

exact-match-function.py
1def exact_match(expected: str, prediction: str) -> bool: 2 normalized_expected = expected.strip().lower() 3 normalized_prediction = prediction.strip().lower() 4 return normalized_prediction == normalized_expected 5 6print(exact_match("blocked", "escalated")) 7print(exact_match("approved", " APPROVED "))
Output
1False 2True

expected: str and -> bool are type hints. They document intended inputs and output for readers, editors, and static type checkers. Python doesn't enforce those hints when the function runs.[1]Reference 1The Python Tutorial.https://docs.python.org/3/tutorial/ Calling exact_match("approved", 7) still enters the function, then raises AttributeError because an integer has no strip() method.

That failure points to a boundary: editor-visible annotations aren't a parser. Runtime data needs runtime checks as soon as values come from a file instead of a literal in the editor.

JSONL moves the same rows to disk

Hardcoded lists are useful for learning, but an evaluator must read the rows it was given. Store the same three requests in a file using JSONL (JSON Lines), where each line contains one complete JSON value.

Save the stable fixture at eval/access_requests.jsonl:

eval/access_requests.jsonl
1{"prompt": "Access request 101 status?", "expected": "approved", "prediction": "approved"} 2{"prompt": "Access request 102 status?", "expected": "blocked", "prediction": "escalated"} 3{"prompt": "Access request 103 status?", "expected": "restored", "prediction": "restored"}

JSON and Python use similar punctuation, but they aren't the same language. When json.loads parses a line, Python's standard-library json module maps JSON values into Python values:

JSONPython after json.loads
objectdict
arraylist
stringstr
number with no decimal point or exponent, such as 7int
number with a decimal point or exponent, such as 7.0 or 7e0float
true / falseTrue / False
nullNone

Path from the standard-library pathlib module represents a filesystem path. Opening it with encoding="utf-8" makes text decoding explicit.

Iterating over the handle yields one line at a time instead of reading one giant string.[1]Reference 1The Python Tutorial.https://docs.python.org/3/tutorial/ Our loader will still collect the parsed dictionaries in a list, so its memory use grows with the dataset. Streaming the scoring itself is a separate change.

Create the same fixture in the next helper cell so every marked example can run in isolation. If you followed the Git and Docker lessons, that file already exists, and running this cell recreates its three lines.

create-eval-fixture.py
1from pathlib import Path 2 3fixture = Path("eval/access_requests.jsonl") 4fixture.parent.mkdir(exist_ok=True) 5fixture.write_text( 6 "\n".join( 7 [ 8 '{"prompt": "Access request 101 status?", "expected": "approved", "prediction": "approved"}', 9 '{"prompt": "Access request 102 status?", "expected": "blocked", "prediction": "escalated"}', 10 '{"prompt": "Access request 103 status?", "expected": "restored", "prediction": "restored"}', 11 ] 12 ) 13 + "\n", 14 encoding="utf-8", 15) 16print("fixture rows:", len(fixture.read_text(encoding="utf-8").splitlines()))
Output
1fixture rows: 3

Read the file, then print the same receipt

Now connect the file to the receipt. Follow one row through JSON decoding, field checks, comparison, aggregation, and the 2/3 eval gate. If any boundary changes the contract, the receipt should stop matching the Git and Docker chapters.

The Git scorer rejected non-string fields before normalizing labels with .strip().lower(). Replacing that type check with str(label) would turn JSON number 7 into string "7", counting a producer bug as a wrong prediction against "approved". Refusing that row keeps invalid data out of the mismatch count.

The parser needs two kinds of checks. isinstance(value, str) checks a value's type; not value.strip() rejects a string containing no visible text. raise stops normal execution with an exception, and try/except handles a named exception. Around json.loads, that handler adds our file's line number; from error preserves the original decoding error as its cause.

Runtime validation contract: Line 4 is valid JSON, so json.loads produces Python integer 7. The static type hint dict[str, str] is ignored at runtime. The explicit isinstance(value, str) check catches the type violation and raises TypeError before the row can reach the scorer or pollute the metric denominator.
Line 4 is valid JSON, so `json.loads` succeeds and `prediction` becomes the integer `7`. Python type hints provide zero runtime enforcement. The explicit `isinstance(..., str)` check stops the row before scoring, keeping producer bugs out of the evaluation denominator.

Save these definitions in scripts/score_access_requests.py, replacing the earlier scorer. Square brackets in a hint describe contents: dict[str, str] has string keys and values, and list[dict[str, str]] is a list of those dictionaries.

scripts/score_access_requests.py
1import json 2import sys 3from pathlib import Path 4 5REQUIRED_FIELDS = ("prompt", "expected", "prediction") 6DEFAULT_INPUT = Path("eval/access_requests.jsonl") 7 8def parse_example(line: str, line_number: int) -> dict[str, str]: 9 try: 10 row = json.loads(line) 11 except json.JSONDecodeError as error: 12 raise ValueError(f"line {line_number}: invalid JSON") from error 13 14 if not isinstance(row, dict): 15 raise TypeError(f"line {line_number}: expected a JSON object") 16 17 example: dict[str, str] = {} 18 for key in REQUIRED_FIELDS: 19 if key not in row: 20 raise KeyError(f"line {line_number}: missing {key}") 21 22 value = row[key] 23 if not isinstance(value, str): 24 raise TypeError(f"line {line_number}: {key} must be a string") 25 if not value.strip(): 26 raise ValueError(f"line {line_number}: {key} must not be empty") 27 28 example[key] = value 29 30 return example 31 32def load_examples(path: Path) -> list[dict[str, str]]: 33 examples = [] 34 35 with path.open("r", encoding="utf-8") as handle: 36 for line_number, line in enumerate(handle, start=1): 37 if line.strip(): 38 examples.append(parse_example(line, line_number)) 39 40 if not examples: 41 raise ValueError("input file has no examples") 42 43 return examples 44 45def exact_match(expected: str, prediction: str) -> bool: 46 return expected.strip().lower() == prediction.strip().lower() 47 48def score_examples(examples: list[dict[str, str]]) -> tuple[int, int, float]: 49 if not examples: 50 raise ValueError("examples must not be empty") 51 52 matches = 0 53 for example in examples: 54 if exact_match(example["expected"], example["prediction"]): 55 matches += 1 56 57 total = len(examples) 58 return matches, total, matches / total 59 60def passes_gate(matches: int, total: int) -> bool: 61 return matches * 3 >= total * 2 62 63def main(path: Path) -> None: 64 examples = load_examples(path) 65 matches, total, score = score_examples(examples) 66 print(f"Eval rows: {total}") 67 print(f"Exact-match accuracy on tiny fixture: {score:.3f} ({matches}/{total})") 68 if not passes_gate(matches, total): 69 print("Gate failed: score regressed below 2/3", file=sys.stderr) 70 raise SystemExit(1) 71 print("Gate passed. You may commit.")

Read the loader from the outside in. if runs its indented block only when the condition is True, and matches += 1 means matches = matches + 1.

The three values after return form a tuple; matches, total, score = ... unpacks it into three names. A with block closes the file even if parsing raises an exception.

The loader deliberately ignores blank lines, but never skips a malformed nonblank row. It also ignores extra object fields after copying the three required strings. These are choices in this evaluator, not rules imposed by JSON. if not examples rejects an empty list before division could use a zero denominator.

passes_gate keeps the rule in integer counts, the same form Git already used: fail when matches * 3 < total * 2. For a positive total, multiplying matches / total >= 2 / 3 by 3 * total gives that exact count comparison. The caller supplies valid counts from a nonempty list; this helper doesn't validate arbitrary counts.

The saved module also needs a small command-line wrapper. sys.argv[0] is the script name, and sys.argv[1] is the optional path. Add it at the bottom so direct execution and imports have different behavior:

scripts/score_access_requests.py
1if __name__ == "__main__": 2 if len(sys.argv) > 2: 3 raise SystemExit("usage: uv run scripts/score_access_requests.py [examples.jsonl]") 4 5 input_path = Path(sys.argv[1]) if len(sys.argv) == 2 else DEFAULT_INPUT 6 main(input_path)

The if __name__ == "__main__" guard calls main when you run the file directly. Importing the functions in a test doesn't score a file as a side effect.

Run the default path once against the stable fixture. Before you do, predict the same three lines from the earlier receipt:

run-the-scorer.py
1main(DEFAULT_INPUT)
Output
1Eval rows: 3 2Exact-match accuracy on tiny fixture: 0.667 (2/3) 3Gate passed. You may commit.

From the shell, both commands should print the same three-line receipt. The first uses DEFAULT_INPUT; the second proves that an explicit path follows the same route:

terminal
1uv run scripts/score_access_requests.py 2uv run scripts/score_access_requests.py eval/access_requests.jsonl

Trace request 102 through the boundaries

When the score surprises you, follow one value instead of staring at the final number. Request 102 is valid input, yet its labels differ. Its trace should end in one zero, not an input error.

BoundaryValue for request 102New guarantee
file iterationone line of textline number is known
json.loadsa dictionaryJSON syntax was valid
field loopthree non-empty stringsrow contract was valid
exact_matchFalsenormalized labels differed
score_examplesone zero among [1, 0, 1]aggregate is 2 / 3
passes_gateTrue2 * 3 >= 3 * 2

That order gives each failure a location. A malformed line should fail while parsing, not disappear from the denominator or turn into a wrong prediction.

Errors should name the broken boundary

Suppose request 102 suddenly disappears from a run. Before changing the metric, ask whether Python ever accepted that row.

A syntax error means Python couldn't parse the program. An exception means valid Python hit a problem while running. The final line of a traceback gives the exception type and message; lines above it show the call path.[1]Reference 1The Python Tutorial.https://docs.python.org/3/tutorial/

Start at the final exception line, then work upward to the call that failed:

SymptomCauseFirst check
FileNotFoundErrorinput path doesn't existprint the path and run ls eval/
ValueError: line 2: invalid JSONquotes, commas, or braces are malformedinspect line 2 as raw text
KeyError: 'line 2: missing prediction'required field is absentcompare keys with REQUIRED_FIELDS
TypeError: line 2: prediction must be a stringJSON contains a number, list, Boolean, or nullinspect the value and fix the producer
ValueError: input file has no examplesfile is empty or only whitespaceverify the fixture and path

Catch an exception only when your code can add context, recover, or turn it into a deliberate command-line exit. Continuing after every exception would let broken rows produce a misleading metric.

The next cell deliberately sends a number where prediction must be text. Predict the boundary first: JSON decoding should succeed, field validation should reject the value, and scoring should never run.

break-one-row.py
1bad_line = '{"prompt": "Access request 104 status?", "expected": "approved", "prediction": 7}' 2 3try: 4 parse_example(bad_line, line_number=4) 5except TypeError as error: 6 print(type(error).__name__) 7 print(error)
Output
1TypeError 2line 4: prediction must be a string

The exception is expected here, so catching it makes the output visible. In the real loading path, let it stop the run. That preserves the distinction between an invalid input row and a valid row the model got wrong.

Keep the fraction separate from its display

The receipt displays 0.667, while the scorer stores 2 / 3. Those values look interchangeable until a gate compares them.

Python's float type uses binary floating-point arithmetic. Many fractions, including 2 / 3, have no exact finite representation. Formatting to three decimal places produces a readable report; it doesn't change the stored value.[1]Reference 1The Python Tutorial.https://docs.python.org/3/tutorial/

check-score-rounding.py
1import math 2 3matches = 2 4total = 3 5score = matches / total 6 7print("stored:", score) 8print("displayed:", f"{score:.3f}") 9print("equal to 0.667:", score == 0.667) 10print("close to 2/3:", math.isclose(score, 2 / 3)) 11print("count floor:", matches * 3 >= total * 2)
Output
1stored: 0.6666666666666666 2displayed: 0.667 3equal to 0.667: False 4close to 2/3: True 5count floor: True

Keep matches and total for exact checks on this discrete metric. Use math.isclose when two independently computed floating-point results should be approximately equal. Never compare the stored score with its rounded display string.

The count floor stays exact in integer form: 2 * 3 >= 3 * 2 doesn't care that 2 / 3 isn't the literal 0.667.

Cover the contracts with pytest

The manual run proved one path once. A test suite lets you rerun the boundary decisions after every edit. Add pytest as a development dependency; uv updates pyproject.toml and uv.lock.[3]Reference 3pytest Documentationhttps://docs.pytest.org/

terminal
1uv add --dev pytest

Add this section to pyproject.toml, preserving the existing project fields, so pytest can import scripts.score_access_requests from the project root:

pyproject.toml
1[tool.pytest.ini_options] 2pythonpath = ["."]

Create a tests/ directory and save these checks as tests/test_score_access_requests.py. pytest discovers functions whose names begin with test_. An assert fails if its condition is false; pytest.raises instead expects the enclosed operation to raise the named exception and can check its message. A missing or different exception fails the test.[3]Reference 3pytest Documentationhttps://docs.pytest.org/

tests/test_score_access_requests.py
1from pathlib import Path 2 3import pytest 4 5from scripts.score_access_requests import ( 6 exact_match, 7 load_examples, 8 parse_example, 9 passes_gate, 10 score_examples, 11) 12 13def test_exact_match_normalizes_case_and_whitespace(): 14 assert exact_match("approved", " APPROVED ") 15 16def test_parse_example_rejects_non_string_prediction(): 17 line = '{"prompt": "Q", "expected": "approved", "prediction": 7}' 18 with pytest.raises(TypeError, match="line 4: prediction must be a string"): 19 parse_example(line, line_number=4) 20 21def test_load_examples_rejects_empty_file(tmp_path: Path): 22 empty_file = tmp_path / "empty.jsonl" 23 empty_file.write_text("", encoding="utf-8") 24 with pytest.raises(ValueError, match="input file has no examples"): 25 load_examples(empty_file) 26 27def test_score_keeps_exact_counts(): 28 examples = [ 29 {"prompt": "101", "expected": "approved", "prediction": "approved"}, 30 {"prompt": "102", "expected": "blocked", "prediction": "escalated"}, 31 {"prompt": "103", "expected": "restored", "prediction": "restored"}, 32 ] 33 matches, total, score = score_examples(examples) 34 assert (matches, total) == (2, 3) 35 assert score == matches / total 36 37def test_gate_uses_counts_not_the_display(): 38 assert passes_gate(2, 3) 39 assert not passes_gate(1, 3) 40 assert passes_gate(2, 2)

tmp_path is a pytest fixture: a test that requests this parameter gets its own temporary directory as a pathlib.Path.[3]Reference 3pytest Documentationhttps://docs.pytest.org/

The last test is the trap. Two correct rows out of two produce 2 / 2, and the 2/3 floor still passes because 2 * 3 >= 2 * 2. Deleting the hard row improved the metric without failing the gate.

Run only this file while learning:

terminal
1uv run python -m pytest tests/test_score_access_requests.py -q

Expected summary:

Output
1..... 25 passed

Each test protects a different contract. Normalization covers a valid edge case, while the parser and empty-file tests exercise deliberate failures. Exact counts stay attached to the displayed float. The gate test exposes a limit of an accuracy floor: it doesn't freeze the fixture itself.

A lockfile doesn't lock the metric

The environment can be identical while the metric changes. Remove request 102 and neither Python nor pytest needs to change for the score to jump.

Aligned comparison shows the denominator trap: In the committed fixture with 3 rows, request 102 is a mismatch, yielding accuracy 2 / 3 = 0.667. When request 102 is deleted from the file, accuracy jumps to 2 / 2 = 1.000. Both pass the 2/3 floor gate, and uv.lock is identical, proving that lockfiles cannot protect against dataset erosion.
Inspect the side-by-side comparison: request 102 is deleted rather than solved. Both runs pass the floor and `uv.lock` is completely unchanged. Audit `git diff -- eval/` to verify the denominator before celebrating a score change.
PieceEvidence
interpreter.python-version chooses Python 3.12
dependenciespyproject.toml plus uv.lock describe pytest and its resolution
inputcommitted eval/access_requests.jsonl contains the stable three rows
commanduv run scripts/score_access_requests.py prints the same receipt

On a clean checkout, verify the lockfile and then run the scorer against the committed rows:[2]Reference 2Locking and Syncinghttps://docs.astral.sh/uv/concepts/projects/sync/

terminal
1uv sync --locked 2uv run python -m pytest tests/test_score_access_requests.py -q 3uv run scripts/score_access_requests.py

--locked asks uv to fail instead of updating an out-of-date lockfile. A successful clean sync confirms that declared project state can create the environment.

It says nothing about whether the JSONL denominator changed. Git records the reviewed fixture, but doesn't prevent a later edit. Use git diff -- eval/access_requests.jsonl to inspect local changes, and compare against the same committed fixture when evaluating two versions of a scorer. Data, random state, hardware, and external model behavior still need their own controls.

This scorer has no randomness, which is ideal for a first lab. Later simulations and training code should create and seed their random-number generators explicitly, record the seed, and still avoid promising bit-for-bit equality across every device and library implementation.

Practice by changing one contract at a time

Use the same fixture for each exercise. Predict the failure or receipt first, make one edit, run the scorer and tests, then restore the file before the next change. Each edit should answer one question about a boundary.

  1. Add request 104 with expected: "approved" and prediction: "approved".
  2. Put spaces around request 101's prediction.
  3. Change request 103's prediction to the JSON number 7.
  4. Delete request 102's prediction key.
  5. Remove a closing brace from request 101.
  6. Delete request 102 entirely and leave 101 and 103 in place.

Compare your observation with this feedback. Notice which changes stop before scoring and which ones alter the denominator:

ChangeExpected behaviorReason
fourth correct row3 / 4 = 0.750 and Gate passed3 * 3 >= 4 * 2, so the floor still clears
surrounding spacesreceipt stays 0.667 (2/3)strip() removes outer whitespace
prediction 7TypeError names line and fieldruntime validation rejects a non-string
missing predictionKeyError names line and fieldrequired-key check fails before scoring
broken braceValueError reports invalid JSONjson.loads can't decode the line
delete request 1021.000 (2/2) and Gate passedthe hard row left the denominator

Adding a correct row raises the measured fraction to 3 / 4; it doesn't demonstrate that the model improved because the evaluation set changed. Deleting request 102 also clears the floor, but removes evidence of a mistake. Compare model versions on the same rows before attributing a score change to the model.

To see the gate fail, add a fourth row whose labels differ: 2 / 4 is below 2 / 3, so the receipt should print Gate failed.

Why is silently skipping a malformed row worse than stopping the run?

Answer

Skipping changes the denominator and can make the metric look better without disclosing missing data. Stopping keeps input failure separate from model quality.

Same habit, next on arrays

One small program now exposes every boundary. JSON text becomes a checked dictionary; a loop visits rows; a function decides exact match; exact counts become a displayed score; and a count floor decides whether the receipt may say the gate passed. Failures point to syntax, file access, JSON decoding, field validation, or metric logic instead of collapsing into one bad number.

The next lesson replaces row dictionaries with numerical arrays. Field names such as expected were the contract here. Axis names and sizes become the contract there, so indexing, broadcasting, and reductions need the same explicit checks before you trust their output.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A nonblank JSONL line is valid JSON but contains ["approved", "approved"] instead of an object with named fields. Where should parse_example stop it?

Correct answer: json.loads should decode the list, then the top-level schema check should raise TypeError before field validation.

A JSON array is valid JSON, so decoding succeeds and returns a Python list. The evaluator requires one object with named fields per line, so parse_example should reject the wrong top-level type before looking for required keys or scoring the row.

2.The row results are [True, False, True]. Why does sum(row_results) / len(row_results) produce 2 / 3?

Correct answer: Python treats True as 1 and False as 0 in this numeric sum, so the numerator is two of three rows.

The three Booleans contribute 1, 0, and 1, so their sum is 2. len counts all three rows, including the incorrect one, which keeps the denominator honest.

3.What do expected: str and -> bool guarantee when Python runs exact_match?

Correct answer: They document intended types but don't perform runtime validation by themselves.

Type hints help readers, editors, and static checkers. Data arriving at runtime still needs checks such as isinstance(value, str) before the scorer trusts it.

4.Line 4 contains a valid JSON object whose prediction value is the number 7. Where should the rebuilt scorer fail?

Correct answer: During field validation, raising a TypeError that names line 4 and prediction before scoring.

The JSON syntax is valid, so decoding succeeds. The scorer's schema requires strings, so runtime validation should stop the row before normalization or scoring. Coercing with str(7) would hide the producer bug inside the mismatch count.

5.The stored score is 2 / 3, and the report displays 0.667. Which check is sound?

Correct answer: Keep (matches, total) == (2, 3) or compare computed floats with math.isclose.

0.667 is a rounded display, not the stored value of 2 / 3. Exact counts preserve this metric's discrete evidence, while math.isclose is suitable for approximate float comparison.

6.A test wraps parse_example(bad_line, 4) in pytest.raises(TypeError). The parser returns a dictionary instead of raising. What happens?

Correct answer: The test fails because the enclosed call didn't raise the exception the test requires.

pytest.raises checks a failure contract: the parser must reject this input with TypeError. Returning normally violates that contract, even if the returned object looks valid.

7.Which project state should be committed so a teammate can recreate the environment without copying your machine's installed packages?

Correct answer: Commit pyproject.toml, .python-version, and uv.lock; leave .venv/ uncommitted.

The project, Python pin, and lockfile are portable inputs. The virtual environment is machine-local output that uv sync can recreate.

8.Request 102 is deleted from the fixture, leaving two correct rows. The scorer prints 1.000 (2/2) and Gate passed. What failed?

Correct answer: The floor still passes on 2/2, so deleting a hard row raises the metric while reducing fixture coverage.

passes_gate(2, 2) is true because 2 * 3 >= 2 * 2. The floor doesn't enforce the original three-row fixture. Git preserves that baseline for comparison, but someone still needs to check that the scored rows match it.

9.One nonblank JSONL row is missing prediction. A teammate proposes catching the error and continuing so the 99 remaining rows can still be scored. What should the scorer do?

Correct answer: Raise a line-specific KeyError before scoring; skipping the row would silently change the evaluation denominator.

A missing required field is an input-contract failure, not a model prediction. Stopping before aggregation keeps malformed data separate from model quality and preserves evidence that the intended denominator was not fully scored. Skipping or imputing the row would silently change the metric.

9 questions remaining.

Next Step
Continue to NumPy and Tensor Shapes

The scorer now names fields and counts before it trusts a printed metric. NumPy asks for the same honesty about axes: once rows become arrays, every dimension needs a name and size before indexing, broadcasting, or a reduction can be trusted.

PreviousDocker for Reproducible AI
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

The Python Tutorial.

Python Software Foundation. · 2026 · Python Documentation

https://docs.python.org/3/tutorial/

Locking and Syncing

Astral · 2026

https://docs.astral.sh/uv/concepts/projects/sync/

pytest Documentation

pytest contributors · 2026 · Official pytest documentation

https://docs.pytest.org/

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.