Batch inference
- Lesson 4
- 40 min
- Module 2: Choose a serving pattern
Lesson 4 of 15 · Deploying AI Models for Real UsersBatch inference
1. Define the inference contract
2. Choose a serving pattern
3. Build the prediction service
4. Choose infrastructure and measure
5. Release, monitor and recover
What you will be able to do
Score every new ticket of the day in one scheduled run, so that Grace’s team has its review list each morning. A few bad rows must not stop the run, and a broken file must not produce a list that looks correct.
Before you startWhat to know and to have ready
What you need to know
- The escalation model, its threshold 0.19 and the 20% review capacity, from Thresholds and calibration in Machine Learning: From Problem to Reliable Model.
- How
predict.pyscored the July file and stopped on bad input, from Plan production checks in the machine learning course. - The contract (
TicketIn, missing key versus null), from Prediction inputs and outputs. - The bundle and its digest,
MODEL_SHA256, from Bundle model dependencies and Artifact trust and integrity.
What you need on your computer
- The project
~/projects/escalation-service(Windows:$HOME\projects\escalation-service) with its.venv, active in a terminal. escalation/contract.pyand the tests, from Prediction inputs and outputs.bundles/escalation-1.0.0/withSHA256SUMS, andescalation/bundle.py, from Artifact trust and integrity.python -m pytestgives18 passed.- The machine learning course’s
~/projects/ticket-model/data/july_tickets.csv. If you do not have it, downloadjuly_tickets.csvfrom that course’s data folder in the labs. - If your project is missing or broken, copy the snapshot
end-of-m01. Use a snapshot has the steps.
Tested with Python 3.14.6 and Docker Desktop (Docker Engine 29.6.1) on macOS, Apple silicon. The Windows and Linux steps are not tested.
The post comes once a day
Section titled “The post comes once a day”The post arrives once a day. Nobody waits at the door for each letter: you read them all at breakfast. Much of the work with a model can be done the same way. Collect the cases, score them all at once, and deliver the results before people need them.
Larkfield’s help desk works like this today. Each night at 02:00, Mei’s export writes a file with the tickets of the day before. The model scores the file before the senior team starts at 08:00, and Grace’s team reviews the flagged tickets first. The data in this course is the synthetic Larkfield data from the machine learning course.
Text description of the diagram
Three actors: the help desk system, the batch job of escalation-service, and the senior team. All day, new tickets arrive without a score. At 02:00, Mei’s nightly file with the tickets of one day goes to the batch job. The job checks every row, scores the tickets in chunks of 500, and writes three files: scores.csv, rejected.csv and summary.json. 1,400 tickets take about 1 second. At 08:00, the senior team opens scores.csv and sees yesterday’s flagged tickets. The last note: a ticket that arrived at 09:05 yesterday gets its flag about 23 hours later.
Look at the last note first. That delay is the price of batch work, and the next lesson removes it. This lesson makes the nightly run safe.
The problem: one bad row, no list
Section titled “The problem: one bad row, no list”In the machine learning course, predict.py checked the whole file first, and any failed check stopped the run with exit code 1. Nothing was scored. That is safe, but it is too strict for a file that arrives every night.
Count what one typo costs. The July file has 1,400 tickets. Suppose one agent typed urgent in the priority field. That is 1 bad row in 1,400, or 0.07%. With that course’s rule, the other 1,399 tickets get no score, and Grace’s team starts the day with no list at all.
The opposite mistake is worse. Suppose the job does not check, or it crashes after it has written half of scores.csv. Grace’s team opens a file that looks normal, with 700 tickets instead of 1,400. Nobody can see that half of the flags are missing.
Tomás asks: “Why not loop over the file and call the model? It is ten lines.” The ten lines give scores. They do not answer three questions that the morning review depends on:
- Which rows were not scored, and why?
- Is the output file complete, or did the run stop halfway?
- Which model scored this file, and does a second run give the same scores?
The idea: batch scoring
Section titled “The idea: batch scoring”Batch scoring means scoring many cases together in one run, usually on a schedule, and storing the results for later use. Nobody waits for a single answer. So the important number is throughput, the number of cases scored per second, not the time of one case. On the test computer, the job scored the 1,400 July tickets in 0.75 seconds, with the time to load the model. That is about 1,900 tickets per second.
A safe batch job answers the three questions with three rules.
Set bad rows aside, but stop a broken file. Each row goes through the contract from Module 1. A good row is scored. A bad row goes to rejected.csv, with its row number and the problem, and the run continues. But if many rows are bad, the file itself is probably broken, for example after a change in Mei’s export. So the job has a limit: if more than 5% of the rows are bad, it fails with exit code 1 and writes no scores.csv. This is partial failure handling: a small failure stays small, and a large one stops the run.
Never show half a file. The job writes each output under a temporary name, such as scores.csv.partial. At the end, it renames the file to scores.csv. The file system does a rename in one step, so a reader sees either no file or the complete file. This is an atomic write: it happens completely, or not at all.
Record what made the output. Each run writes summary.json. It records the rows scored and rejected, the flag rate, the model version, the bundle digest and the SHA-256 of the input file. Each run also writes into its own folder, such as out/2026-07-31/, so a new run never overwrites an old result.
Text description of the diagram
Mei’s file goes to a first question: is every column there? If not, the run stops with exit code 2, and nothing is written. If every column is there, each row is checked with the contract. Good rows are scored, 500 at a time. Bad rows go to rejected.csv, with the row number, the ticket_id and the problem. Then a second question: are the bad rows at most 5% of the file? If yes, the run is done, with exit code 0, and scores.csv.partial is renamed to scores.csv. If no, the run fails with exit code 1, and no scores.csv exists. summary.json is written in both cases.
Build the batch job
Section titled “Build the batch job”The batch job needs three new files in escalation/. Each one has one job, and the service in Module 3 uses the first two again.
settings.py: the settings from the environment
Section titled “settings.py: the settings from the environment”The job must know which bundle to load and which digest to expect. As in the API and Docker courses, the settings come from environment variables, and a wrong setting stops the program before it does any work. Make escalation/settings.py:
"""Settings from environment variables. A wrong setting stops the service at start."""
import osfrom dataclasses import dataclass, fieldfrom pathlib import Path
class SettingsError(Exception): """A required setting is missing or not valid. The service must not start."""
@dataclass(frozen=True)class Settings: model_bundle: Path model_sha256: str api_key: str | None = field(repr=False) log_level: str = "INFO" show_docs: bool = True max_job_tickets: int = 5000 max_queued_jobs: int = 4 extra_ms_per_ticket: float = 0.0
def read_secret(name: str) -> str | None: """Return the value of NAME, or the contents of the file in NAME_FILE.""" path = os.environ.get(f"{name}_FILE") if path: try: return Path(path).read_text(encoding="utf-8").strip() or None except OSError as error: raise SettingsError(f"{name}_FILE: cannot read {path}: {error.strerror}") return os.environ.get(name) or None
def load_settings() -> Settings: env = os.environ sha = env.get("MODEL_SHA256", "").strip().lower() if len(sha) != 64: raise SettingsError("MODEL_SHA256 must be the 64-character digest of the bundle") settings = Settings( model_bundle=Path(env.get("MODEL_BUNDLE", "bundles/escalation-1.0.0")), model_sha256=sha, api_key=read_secret("API_KEY"), log_level=env.get("LOG_LEVEL", "INFO").upper(), show_docs=env.get("SHOW_DOCS", "true").lower() == "true", max_job_tickets=int(env.get("MAX_JOB_TICKETS", "5000")), max_queued_jobs=int(env.get("MAX_QUEUED_JOBS", "4")), extra_ms_per_ticket=float(env.get("EXTRA_MS_PER_TICKET", "0")), ) if env.get("REQUIRE_API_KEY", "false").lower() == "true" and not settings.api_key: raise SettingsError("REQUIRE_API_KEY is true, but API_KEY is not set") return settingsThe batch job uses only the first two settings. MODEL_BUNDLE has a default. MODEL_SHA256 has none, because the digest must come from you, not from the bundle. The API key and the documentation settings are for the HTTP service in Module 3. The three job settings are for Asynchronous inference.
scoring.py: one function for every pattern
Section titled “scoring.py: one function for every pattern”Make escalation/scoring.py. The batch job, the API and the jobs all call this one function, so every pattern scores a ticket in exactly the same way:
"""Score tickets with a loaded bundle. The API and the batch job both use this.
The work for each request is split into three timed steps, so that abenchmark can show where the time goes:
frame the checked tickets → a table with the training columns prepare the pipeline's preprocessing (one-hot, impute, scale) model the logistic regression's score"""
import timefrom dataclasses import dataclass, field
from escalation.bundle import Bundlefrom escalation.contract import TicketFeatures, to_frame, unknown_categories
@dataclassclass Result: score: float flag: bool warnings: list[str]
@dataclassclass Timings: """Milliseconds spent in each step of one call."""
steps: dict[str, float] = field(default_factory=dict)
def header(self) -> str: """The value of a Server-Timing header, for example "frame;dur=0.41".""" return ", ".join(f"{name};dur={ms:.2f}" for name, ms in self.steps.items())
def score_tickets(bundle: Bundle, tickets: list[TicketFeatures]) -> tuple[list[Result], Timings]: timings = Timings() started = time.perf_counter()
frame = to_frame(tickets) now = time.perf_counter() timings.steps["frame"] = (now - started) * 1000
preprocess, model = bundle.pipeline[:-1], bundle.pipeline[-1] prepared = preprocess.transform(frame) started, now = now, time.perf_counter() timings.steps["prepare"] = (now - started) * 1000
scores = model.predict_proba(prepared)[:, 1] started, now = now, time.perf_counter() timings.steps["model"] = (now - started) * 1000
results = [ Result( score=float(score), flag=bool(score >= bundle.threshold), warnings=unknown_categories(ticket, bundle.known), ) for ticket, score in zip(tickets, scores) ] return results, timingsscore_tickets takes a list of checked tickets and times its three steps. The batch job ignores the times; the next lesson uses them. The pipeline is split only to time the steps. pipeline[:-1] is the preprocessing and pipeline[-1] is the model, so the score is the same as from pipeline.predict_proba.
batch.py: the job
Section titled “batch.py: the job”Make escalation/batch.py. The first part reads and checks the rows:
"""Score a file of tickets with the same bundle and contract as the API.
python -m escalation.batch data/july_tickets.csv out/2026-07
Writes three files into the output folder:
scores.csv ticket_id, score, flag, model_version (one row per good ticket) rejected.csv row, ticket_id, problem (one row per ticket that fails the contract) summary.json what was read, what was written, with which model, and how long it took
A bad row does not stop the job: it is set aside in rejected.csv, and theother rows are scored. But if more than --max-rejected of the rows are bad,the input itself is probably broken, so the job fails (exit code 1) andwrites no scores.csv. Files are written under a temporary name and renamed atthe end, so a reader never sees half a file."""
import argparseimport hashlibimport jsonimport sysimport timefrom datetime import datetime, timezonefrom pathlib import Path
import pandas as pdfrom pydantic import ValidationError
from escalation.bundle import BundleError, load_bundlefrom escalation.contract import FEATURES, TicketInfrom escalation.scoring import score_ticketsfrom escalation.settings import SettingsError, load_settings
COLUMNS = ["ticket_id"] + FEATURES
def problem(error: ValidationError) -> str: first = error.errors()[0] where = ".".join(str(p) for p in first["loc"]) or "row" return f"{where}: {first['msg']}"
def check_rows(chunk: pd.DataFrame, first_row: int): good, bad = [], [] for offset, row in enumerate(chunk.to_dict("records")): values = {k: (None if pd.isna(v) else v) for k, v in row.items()} try: good.append(TicketIn(**values)) except ValidationError as error: bad.append({"row": first_row + offset, "ticket_id": values.get("ticket_id"), "problem": problem(error)}) return good, bad
def write_atomic(frame: pd.DataFrame, path: Path) -> None: temporary = path.with_suffix(path.suffix + ".partial") frame.to_csv(temporary, index=False) temporary.replace(path)Three details matter. check_rows sends each row through TicketIn, the same contract as the API, so a ticket that the API refuses is also refused here. An empty cell becomes None (null), not 0, because the pipeline’s imputer must see a missing value. write_atomic writes the .partial file, then renames it with replace.
The second part runs the job:
def main() -> int: parser = argparse.ArgumentParser(description="Score a CSV file of tickets.") parser.add_argument("input", type=Path) parser.add_argument("output", type=Path, help="a folder for scores.csv, rejected.csv and summary.json") parser.add_argument("--chunk", type=int, default=500, help="tickets per chunk") parser.add_argument("--max-rejected", type=float, default=0.05, help="the largest share of bad rows") args = parser.parse_args()
started_at = datetime.now(timezone.utc) started = time.perf_counter() try: settings = load_settings() bundle = load_bundle(settings.model_bundle, settings.model_sha256) except (SettingsError, BundleError) as error: print(f"Start-up error: {error}", file=sys.stderr) return 2 try: reader = pd.read_csv(args.input, usecols=COLUMNS, dtype=str, keep_default_na=False, na_values=[""], chunksize=args.chunk) except ValueError as error: # a missing column print(f"Stopped: {error}", file=sys.stderr) return 2
scored, rejected, rows = [], [], 0 for chunk in reader: good, bad = check_rows(chunk, first_row=rows + 2) # row 1 is the header rows += len(chunk) rejected += bad if good: results, _ = score_tickets(bundle, good) scored += [{"ticket_id": t.ticket_id, "score": r.score, "flag": int(r.flag), "model_version": bundle.version} for t, r in zip(good, results)]
args.output.mkdir(parents=True, exist_ok=True) scores = pd.DataFrame(scored, columns=["ticket_id", "score", "flag", "model_version"]) write_atomic(pd.DataFrame(rejected, columns=["row", "ticket_id", "problem"]), args.output / "rejected.csv") failed = rows == 0 or len(rejected) / rows > args.max_rejected if not failed: write_atomic(scores, args.output / "scores.csv") summary = { "input": str(args.input), "input_sha256": hashlib.sha256(args.input.read_bytes()).hexdigest(), "rows": rows, "scored": len(scores), "rejected": len(rejected), "flagged": int(scores["flag"].sum()), "flag_rate": round(float(scores["flag"].mean()), 3) if len(scores) else None, "model_version": bundle.version, "bundle_sha256": bundle.digest, "started_at": started_at.isoformat(timespec="seconds"), "seconds": round(time.perf_counter() - started, 2), "status": "failed: too many rejected rows" if failed else "done", } (args.output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") print(f"Read {rows} rows: scored {len(scores)}, rejected {len(rejected)}, " f"flagged {summary['flagged']} ({summary['flag_rate']}). Model {bundle.version}. {summary['seconds']} s.") if failed: print(f"Failed: {len(rejected)} of {rows} rows were rejected (the limit is {args.max_rejected:.0%}). " f"No scores.csv was written. See {args.output / 'rejected.csv'}.", file=sys.stderr) return 1 return 0
if __name__ == "__main__": sys.exit(main())The job loads the bundle once, with all six checks of load_bundle, before it reads a single row. It reads the file in chunks of 500, so a file of a million rows never needs a million rows in memory. The exit code tells the scheduler what happened: 0 done, 1 too many bad rows. 2 means that the job could not start, or the file has the wrong columns.
Worked example: 20 tickets, then July
Section titled “Worked example: 20 tickets, then July”Amira starts small, so that every number is visible. She copies the header and the first 20 tickets of July into data/twenty.csv. In row 4 (ticket T-119235), she changes the priority from 3 to urgent. One bad row in 20 is exactly 5%, the limit.
Predict: does the run finish? How many tickets are scored?
python -m escalation.batch data/twenty.csv out/twentyThe output:
Read 20 rows: scored 19, rejected 1, flagged 3 (0.158). Model 1.0.0. 1.16 s.out/twenty/rejected.csv names the row, the ticket and the problem:
row,ticket_id,problem4,T-119235,"priority: Input should be a valid integer, unable to parse string as an integer"scores.csv has 19 rows. T-119235 is not in it, and the other tickets keep their order:
ticket_id,score,flag,model_versionT-119233,0.037624397312637836,0,1.0.0T-119234,0.255169256886106,1,1.0.0T-119236,0.06880610647988575,0,1.0.0...The limit says “more than 5%”, and 1 in 20 is not more. Now Amira also changes created_hour in row 9 to 24. Two bad rows in 20 are 10%. She runs the job into out/twenty-two.
The output:
Read 20 rows: scored 18, rejected 2, flagged 2 (0.111). Model 1.0.0. 0.79 s.Failed: 2 of 20 rows were rejected (the limit is 5%). No scores.csv was written. See out/twenty-two/rejected.csv.The exit code is 1. out/twenty-two/ holds only rejected.csv, with rows 4 and 9, and summary.json, with "status": "failed: too many rejected rows". The job scored 18 tickets, but it does not write them. A list with 10% of the tickets missing is not a list that Grace’s team should trust.
Now the real file. Mei’s nightly file has about 45 tickets. To test with more, Amira scores all 1,400 July tickets in one run:
python -m escalation.batch data/july_tickets.csv out/2026-07The output:
Read 1400 rows: scored 1400, rejected 0, flagged 282 (0.201). Model 1.0.0. 0.75 s.282 flagged tickets, 20.1%: the same as the machine learning course’s predict.py gave for July. The same model with the same preprocessing gives the same answer. summary.json records the run:
{ "input": "data/july_tickets.csv", "input_sha256": "10dffc093993341a361172d0aa6ea0e7c06521f140cc2a80ee90c713361f7c99", "rows": 1400, "scored": 1400, "rejected": 0, "flagged": 282, "flag_rate": 0.201, "model_version": "1.0.0", "bundle_sha256": "8f233029aa4c896dce1a5b3dfa970cd085088386bbe7784519f15a5b1a433b74", "started_at": "2026-10-08T14:52:07+00:00", "seconds": 0.75, "status": "done"}input_sha256 names the exact input file, and bundle_sha256 names the exact model. Together they answer Grace’s question: “Which model scored this list, from which file?” Your bundle_sha256 is different, because your bundle holds your own training time. Only the course’s bundle in the labs has the digest 8f233029…. Your seconds is different too.
A re-run. Omar asks: “If the job runs twice by mistake, do we get two different lists?” Amira runs it again into out/2026-07-again and compares the two scores.csv files:
shasum -a 256 out/2026-07/scores.csv out/2026-07-again/scores.csvThe output:
2b89ad330e0be98c2fadf3647c83304f6f134fac21511e029234f8d6fec18515 out/2026-07/scores.csv2b89ad330e0be98c2fadf3647c83304f6f134fac21511e029234f8d6fec18515 out/2026-07-again/scores.csvThe same input file and the same bundle give the same bytes. So a re-run is safe: if the 02:00 run fails, the scheduler can run it again.
When batch is the right choice
Section titled “When batch is the right choice”Batch scoring fits when people need the results later, at a known time, and many cases arrive together. Examples: a morning review list, a monthly report, a mailing list. It is the simplest pattern to run. A scheduler starts one command, and nothing must stay up all day. If the run fails, you run it again before anyone needs the results.
It does not fit when a person or a program waits for one answer now. At Larkfield, a ticket that arrives at 09:05 is flagged the next morning, after about 23 hours. For a ticket that can escalate within 72 hours, that is a third of the time gone.
The costs: the delay, a scheduler to start the job, storage for the output folders, and a person who reads rejected.csv. A job that fails at night also needs an alert, or nobody notices until 08:00.
Tomás asks: “The next lesson scores each ticket at once. Then why build batch at all?” Because the batch job stays as the safety net. If the online service is down for an hour, the night run still scores every ticket of the day.
Guided practice: run the batch job
Section titled “Guided practice: run the batch job”-
Make the three files of Build the batch job in
escalation/:settings.py,scoring.pyandbatch.py. -
Make the folder
data/in the project, and copy the July file into it:Terminal window mkdir -p datacp ../ticket-model/data/july_tickets.csv data/Terminal window New-Item -ItemType Directory -Force dataCopy-Item ..\ticket-model\data\july_tickets.csv data\ -
Set
MODEL_SHA256to the digest of your bundle.make_bundle.pyprinted it in Artifact trust and integrity. On your own computer, you can also compute it fromSHA256SUMS:Terminal window export MODEL_SHA256=$(shasum -a 256 bundles/escalation-1.0.0/SHA256SUMS | cut -c1-64)Terminal window $env:MODEL_SHA256 = (Get-FileHash bundles\escalation-1.0.0\SHA256SUMS -Algorithm SHA256).Hash.ToLower() -
Score the July file:
Terminal window python -m escalation.batch data/july_tickets.csv out/2026-07The line starts with
Read 1400 rows: scored 1400, rejected 0, flagged 282 (0.201). -
Open
out/2026-07/summary.json. Check thatinput_sha256starts with10dffc09and thatbundle_sha256equals yourMODEL_SHA256. -
Give the tests their own settings. The job reads
MODEL_BUNDLEandMODEL_SHA256from the environment, so the tests must set them, not your terminal. Intests/conftest.py, add this import afterimport pytest:tests/conftest.py from escalation.bundle import bundle_digestThen add this fixture at the end of the file:
tests/conftest.py @pytest.fixture(autouse=True)def settings_env(monkeypatch):monkeypatch.chdir(ROOT)monkeypatch.setenv("MODEL_BUNDLE", str(BUNDLE))monkeypatch.setenv("MODEL_SHA256", bundle_digest(BUNDLE))for name in ["API_KEY", "API_KEY_FILE", "REQUIRE_API_KEY", "SHADOW_BUNDLE", "SHADOW_SHA256"]:monkeypatch.delenv(name, raising=False)autouse=Trueruns the fixture before every test. It computes the digest of the project’s bundle and removes settings of later modules, so a value in your terminal cannot change a test. The job that a test starts as a command gets the same settings. -
Make
tests/test_batch.py. The two tests run the job as a command, exactly as the scheduler does:tests/test_batch.py import jsonimport subprocessimport sysimport pandas as pdfrom tests.conftest import TICKETdef run_batch(tmp_path, rows):source = tmp_path / "tickets.csv"pd.DataFrame(rows).to_csv(source, index=False)out = tmp_path / "out"result = subprocess.run([sys.executable, "-m", "escalation.batch", str(source), str(out)],capture_output=True, text=True)return result, outdef test_bad_rows_are_set_aside(tmp_path):rows = [{**TICKET, "ticket_id": f"T-{120000 + i}"} for i in range(40)]rows[5]["priority"] = "high"result, out = run_batch(tmp_path, rows)assert result.returncode == 0, result.stderrassert len(pd.read_csv(out / "scores.csv")) == 39rejected = pd.read_csv(out / "rejected.csv")assert rejected.to_dict("records")[0]["row"] == 7assert json.loads((out / "summary.json").read_text())["status"] == "done"def test_too_many_bad_rows_fail_the_job(tmp_path):rows = [{**TICKET, "ticket_id": f"T-{120000 + i}", "created_hour": 25} for i in range(10)]result, out = run_batch(tmp_path, rows)assert result.returncode == 1assert not (out / "scores.csv").exists()assert len(pd.read_csv(out / "rejected.csv")) == 10The first test puts one bad row in 40, at index 5. The header is row 1, so the bad row is row 7 of the file. The second test makes every row bad.
-
Run all the tests with
python -m pytest. The last line says20 passed.
Show the expected test output of step 8
tests/test_batch.py .. [100%]...============================== 20 passed in 2.47s ==============================The 18 tests of Module 1 and the 2 new ones pass. They pass in a new terminal too, without MODEL_SHA256: the fixture settings_env sets it for each test.
Your turn: two empty cells
Section titled “Your turn: two empty cells”In the worked example, the bad values were typos. Now try empty cells. Make a fresh data/twenty.csv from the header and the first 20 tickets of July:
head -21 data/july_tickets.csv > data/twenty.csvGet-Content data\july_tickets.csv -TotalCount 21 | Set-Content data\twenty.csvOpen it in VS Code. In row 4 (T-119235), delete the value of order_value, 186.4, and keep the commas. In row 9 (T-119240), delete the value of team, returns. Before you run python -m escalation.batch data/twenty.csv out/twenty-empty, write down your prediction: which rows are rejected, what is the exit code, and how many tickets are in scores.csv?
Show the answer
The output:
Read 20 rows: scored 19, rejected 1, flagged 2 (0.105). Model 1.0.0. 0.8 s.rejected.csv has one row:
row,ticket_id,problem9,T-119240,team: Input should be a valid stringAn empty cell becomes null. The contract allows null for order_value (no order), so row 4 is scored: T-119235 gets 0.0978 instead of 0.1061 with its order. The contract does not allow null for team, so row 9 is rejected. 1 in 20 is 5%, not more, so the exit code is 0, and scores.csv has 19 tickets. To see the exit code, run echo $? on macOS and Linux, or $LASTEXITCODE in PowerShell.
Common mistake
The job stops with Stopped: Usecols do not match columns
Symptom: one night, the job writes nothing at all, and it exits with code 2:
Stopped: Usecols do not match columns, columns expected but not found: ['prior_escalations_90d']Cause: Mei’s export lost a column. Here, a change in the export left out prior_escalations_90d. The job reads only the columns of the contract (usecols=COLUMNS), and pandas refuses a file without one of them. No row is checked, because without the column no row can be scored correctly.
Fix: fix the export, then run the job again into the same dated folder. Do not “repair” the file with a column of zeros. The model learned that prior_escalations_90d matters, and 0 in every row quietly changes every score.
For a real AI service: a missing column is a problem of the file, not of a row. Stop the whole run, loudly, before scoring. A quiet default is the start of training-serving skew, which Module 3 checks for.
Check your understanding
Mei's file has 1,000 tickets, and 30 of them have created_hour 24. What does the job do, and what does Grace's team see in the morning?
Show the answer
30 of 1,000 is 3%, which is not more than the 5% limit. So the job finishes with exit code 0. scores.csv has the 970 good tickets, and rejected.csv lists the 30 bad ones with their row numbers and the problem created_hour: Input should be less than or equal to 23. summary.json says "rejected": 30 and "status": "done". Grace’s team has its list, and someone should look at the 30 rejected tickets by hand.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Why does the job write scores.csv.partial and then rename it, instead of writing scores.csv directly?
Show the answer
A run can stop in the middle, for example when the computer restarts. If the job wrote scores.csv directly, a reader could find half a file that looks complete. The file system does the rename in one step, so scores.csv is either absent or complete. The atomic write protects the reader from a list with tickets missing.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Check your understanding
Two runs on the same day gave two scores.csv files with different SHA-256 values. Which two fields of summary.json do you compare first, and why?
Show the answer
Compare input_sha256 and bundle_sha256. With the same input and the same bundle, the job gives the same bytes, as the re-run in this lesson showed. So a different output means that the input file changed, or that another model version scored it. For example, Mei’s export ran again with more tickets.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- Batch scoring scores many cases in one scheduled run and stores the results for later. Throughput, the cases per second, matters more than the time of one case: 1,400 tickets in 0.75 s.
- Bad rows go to
rejected.csvwith their row number and problem. More than 5% bad rows fail the run, with noscores.csv. A missing column stops it at once. - Files are written as
.partialand renamed, so nobody reads half a file. summary.jsonrecords the input hash, the bundle digest and the status. The same input and bundle give the same output, so a re-run is safe.- The cost is the wait: a ticket can be almost a day old when its flag arrives.
Next, you score one ticket at the moment an agent opens it, and you agree how long the help desk can wait.
Next: Online inference