Handle messy values
- Lesson 12
- 55 min
- Module 4: Clean and transform data
Lesson 12 of 18 · SQL and Data Preparation for AIHandle messy values
1. Data sources and structure
2. SQL essentials
3. Useful analytical queries
4. Clean and transform data
5. Prepare for modelling safely
6. Data contracts and handoff
What you will be able to do
Turn the profile into decisions, and the decisions into one script. Each problem gets an action that you can explain. The original values stay, and every input row ends in exactly one place.
NotebookDo this lesson in a notebook
The notebook writes the full clean.py in the same parts as this page, and runs it. It has the tasks and checks of the lesson and the module practice. Its setup cells install pandas, build the database and make the extract, so it needs no local setup. Read the explanations here, and run the code there.
Colab and Kaggle open your own copy. They need a free Google or Kaggle account. Stuck? See the solution notebook.
Before you startWhat to know and to have ready
What you need to know
- The problems that the profile found, and what each missing value means, from Profile before changing.
- Loading with
dtype=,drop_duplicates(), the"string"type withfillna(False), and changing rows with.loc, from Transform with Python.
What you need on your computer (only for the local route; the notebook needs none of it)
- The folder
~/projects/ticket-datawith the extractdata/extract/tickets.csv(1,416 rows) anddata/extract/outcomes.csv(1,869 rows), from Reproducible extraction: Guided practice. If they are missing, runpython scripts/extract.pyagain. - The environment
.venvwith pandas 3.0.6, from Profile before changing: The same profile in pandas. Activate it in a terminal in the project folder. The prompt starts with(.venv). scripts/clean.pywithload_tickets(), from Transform with Python: Worked example. This lesson adds to it. If your script is missing or broken, copyscripts/clean.pyfrom the course’s finished scripts. It already has the steps of this lesson: read each part below and compare.reports/profile.md, from Profile before changing: Guided practice. The decisions in this lesson start from it.
Tested on macOS with Python 3.14.6 and pandas 3.0.6. The notebook was tested in Jupyter.
Three actions: exclude, correct, impute
Section titled “Three actions: exclude, correct, impute”For each problem in the profile, you have three possible actions:
- Exclusion: you take the row out of the clean table. You do not delete it: it goes to a quarantine table, with a reject reason. A quarantine table is a separate file for rows that you cannot use with confidence. A person can check those rows later.
- Correction: you change a value to the value that you know is correct, because a rule explains the error. The original stays in a second column.
- Imputation: you replace a missing value with an estimate, for example the median of the column. The estimate is a guess, not a fact.
There is a fourth choice, which is often the best one: keep the value missing, and say why in a flag column.
Look at four made-up tickets:
| ticket_id | channel | order_value | created_at | joined_on | Action | Why |
|---|---|---|---|---|---|---|
| T-1 | Email |
42.50 | 2026-03-02 | 2025-06-01 | correct channel to email |
The rule is clear: the same channel, another spelling. |
| T-2 | chat |
4250 | 2026-01-20 | 2025-11-05 | correct to 42.50 | Only if the rule fits: a web form ticket before 1 February is in cents. T-2 is a chat ticket, so this rule does not fit. Keep 4250, and ask. |
| T-3 | phone |
NULL | 2026-03-03 | 2024-02-11 | keep missing | T-3 is an account ticket: there is no order. |
| T-4 | chat |
64.50 | 2026-03-04 | 2026-03-10 | quarantine | The ticket is older than the account. One of the two dates is wrong, and you cannot know which. |
How to choose:
- Correct only when you know the true value.
- Keep missing when missing has a meaning.
- Quarantine when the row is wrong and you cannot repair it.
- Impute only when the model needs a number, and the value exists but is unknown.
The decisions for Larkfield
Section titled “The decisions for Larkfield”Mei brings the profile to Grace, the support lead. Together they decide each problem. The counts are for the extract after the 14 exact duplicates are removed (1,402 rows):
| Problem | Rows | Action | New value | Original kept in |
|---|---|---|---|---|
| Exact duplicate rows | 14 | remove, and count | (row removed) | the count in reports/cleaning.json |
Other spellings of channel |
39 | correct: strip, lower, webform → web_form |
email, chat, web_form |
channel_raw |
channel is NULL |
38 | keep missing, as its own category | unknown |
channel_raw (NULL) |
segment is Trade |
(customers) | correct: lower-case | trade |
— |
No customer_id (guest) |
12 | correct by meaning | segment guest |
customer_id is still NULL |
Customer not in customers |
9 | keep missing, as its own category | segment unknown |
customer_id |
region is NULL |
139 | keep missing, as its own category | unknown |
— |
order_value NULL, account team |
188 | keep missing | NULL, flag not_applicable |
order_value_raw |
order_value is -1 |
11 | correct the sentinel to a real missing value | NULL, flag unknown |
order_value_raw |
| Web form before 2026-02-01 | 24 | correct: divide by 100 | dollars, flag converted_from_cents |
order_value_raw |
| Created before the customer joined | 3 | quarantine | — | data/clean/quarantine.csv |
word_count above 10,000 |
1 | quarantine | — | data/clean/quarantine.csv |
word_count is 0 |
6 | keep: valid (attachment only) | — | — |
Why quarantine T-10361, the ticket with 48,210 words, and not correct it? The true word count of the customer’s own text is not known. A model that sees 48,210 next to values of about 100 is pulled by one row. Grace can check it by hand and return it later.
Where is the imputation? The 11 unknown order values exist, but nobody knows them. A model needs a number there. Mei decides now that they will get the median order value, with an extra column that says “this value was imputed”. But she does not fill them in the clean table. The median must come from the training rows only, so the imputation happens in Module 5, in Fit transformations correctly. If you compute the median on all rows, the test rows influence the training data.
The map from raw to clean
Section titled “The map from raw to clean”Text description of the diagram
The extract, data/extract/tickets.csv, has 1,416 rows. The first step removes 14 exact duplicates and counts them, which leaves 1,402 rows. The second step corrects channel, segment, region and order_value, and keeps the originals in *_raw columns and a flag. It does not change the number of rows: 1,402. The third step applies the quarantine rules. 1,398 rows pass and go to data/clean/tickets.csv. 4 rows fail: 3 tickets created before the customer joined and 1 ticket with more than 10,000 words. They go to data/clean/quarantine.csv with a reject reason. The file reports/cleaning.json records all three counts.
Worked example: Mei writes the cleaning step
Section titled “Worked example: Mei writes the cleaning step”Mei adds three constants in scripts/clean.py: CHANNELS above TIME_FORMAT, and the other two below it. (TIME_FORMAT is already in the file; it is shown here so you can see where the new lines go.) Each constant holds a decision, with a name:
CHANNELS = {"email", "chat", "phone", "web_form"}TIME_FORMAT = "%Y-%m-%d %H:%M:%S"WEB_FORM_FIXED = pd.Timestamp("2026-02-01") # the web form sent cents before this dayMAX_WORDS = 10_000Then she writes clean_tickets, below load_tickets. It returns the clean rows, the quarantined rows, and a dictionary of counts. Read it part by part.
def clean_tickets(raw): counts = {"input_rows": len(raw)} df = raw.drop_duplicates() counts["exact_duplicates_removed"] = len(raw) - len(df) assert df["ticket_id"].is_unique, "a ticket ID appears twice with different values"
# Channel: one spelling per value. A missing channel stays missing, and # becomes its own category, "unknown", so the model can still use the row. df["channel_raw"] = df["channel"] channel = df["channel"].str.strip().str.lower().replace({"webform": "web_form"}) counts["channel_respelled"] = int((channel != df["channel"]).fillna(False).sum()) df["channel"] = channel.fillna("unknown") assert set(df["channel"]) <= CHANNELS | {"unknown"}
# Segment and region: the customer table spells "Trade" in two ways. # Guests have no customer; deleted customers have an ID but no row. df["segment"] = df["segment"].str.strip().str.lower() no_customer = df["customer_id"].isna() df.loc[no_customer, "segment"] = "guest" df["segment"] = df["segment"].fillna("unknown") df["region"] = df["region"].fillna("unknown")assert df["ticket_id"].is_uniquechecks the profile’s finding: after removing exact copies, no ID is left twice. If a new export has two different rows for one ticket, the script stops here, with a message, instead of keeping both.assert set(df["channel"]) <= CHANNELS | {"unknown"}checks that every channel is now in the allowed list.<=means “is a subset of”. A new spelling in a later export stops the script.- The guests get
guestfirst. Only the rows that are still missing after that getunknown: the 9 tickets of deleted customers.
# Order value: keep the original, then correct or blank it, with a flag. df["order_value_raw"] = df["order_value"] df["order_value_flag"] = "ok" df.loc[df["team"] == "account", "order_value_flag"] = "not_applicable" unknown = df["order_value"] == -1 df.loc[unknown, "order_value"] = pd.NA df.loc[unknown, "order_value_flag"] = "unknown" cents = (df["channel"] == "web_form") & (df["created_at"] < WEB_FORM_FIXED) & df["order_value"].notna() df.loc[cents, "order_value"] = (df.loc[cents, "order_value"] / 100).round(2) df.loc[cents, "order_value_flag"] = "converted_from_cents" counts.update({f"order_value_{k}": int(v) for k, v in df["order_value_flag"].value_counts().items()})The order of the steps matters. The cents rule uses the corrected channel, so the ticket with webform (T-10013) is found too. The rule also skips missing values, so an account ticket from the web form stays missing. The last line counts each flag.
# Rows we cannot correct with confidence go to quarantine, with a reason. df["reject_reason"] = pd.NA df.loc[df["created_at"] < df["joined_on"], "reject_reason"] = "created_before_customer_joined" df.loc[df["word_count"] > MAX_WORDS, "reject_reason"] = "word_count_above_10000" quarantine = df[df["reject_reason"].notna()] clean = df[df["reject_reason"].isna()].drop(columns="reject_reason") counts["quarantined"] = len(quarantine) counts["clean_rows"] = len(clean) assert counts["clean_rows"] + counts["quarantined"] + counts["exact_duplicates_removed"] == counts["input_rows"] return clean, quarantine, countsThe last assert is the row check: clean + quarantined + removed duplicates must equal the input. If a later change drops rows without a reason, the script stops.
The outcome events need two steps. The 30 repeated events (same ticket, time and status, but a new outcome_id) go: Mei keeps the one with the lowest outcome_id, the first one logged. The events of the 4 quarantined tickets go too, because their ticket is not in the clean table.
def clean_outcomes(tickets): df = pd.read_csv(IN / "outcomes.csv", dtype={"csat": "Int64"}, keep_default_na=False, na_values=[""]) df["recorded_at"] = pd.to_datetime(df["recorded_at"], format=TIME_FORMAT) counts = {"input_rows": len(df)} # A retry logged some events twice: same ticket, time and status, new ID. # Keep the first one logged. df = df.sort_values("outcome_id").drop_duplicates(["ticket_id", "recorded_at", "status"], keep="first") counts["repeated_events_removed"] = counts["input_rows"] - len(df) df = df[df["ticket_id"].isin(tickets["ticket_id"])] counts["events_of_quarantined_tickets"] = counts["input_rows"] - counts["repeated_events_removed"] - len(df) counts["clean_rows"] = len(df) return df, countsLast, Mei replaces the temporary main() from lesson 2. The new one writes three CSV files and the counts:
def main(): OUT.mkdir(parents=True, exist_ok=True) REPORTS.mkdir(exist_ok=True) tickets, quarantine, ticket_counts = clean_tickets(load_tickets()) outcomes, outcome_counts = clean_outcomes(tickets) for df in (tickets, quarantine): df["joined_on"] = df["joined_on"].dt.strftime("%Y-%m-%d") tickets.to_csv(OUT / "tickets.csv", index=False, date_format=TIME_FORMAT) quarantine.to_csv(OUT / "quarantine.csv", index=False, date_format=TIME_FORMAT) outcomes.to_csv(OUT / "outcomes.csv", index=False, date_format=TIME_FORMAT) summary = {"tickets": ticket_counts, "outcomes": outcome_counts} (REPORTS / "cleaning.json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") print(json.dumps(summary, indent=2))joined_on is a day without a time. The two strftime lines write it as 2026-04-05, not as 2026-04-05 00:00:00. Predict before you run: how many clean ticket rows?
python scripts/clean.py{ "tickets": { "input_rows": 1416, "exact_duplicates_removed": 14, "channel_respelled": 39, "order_value_ok": 1179, "order_value_not_applicable": 188, "order_value_converted_from_cents": 24, "order_value_unknown": 11, "quarantined": 4, "clean_rows": 1398 }, "outcomes": { "input_rows": 1869, "repeated_events_removed": 30, "events_of_quarantined_tickets": 5, "clean_rows": 1834 }}Mei reconciles each group of numbers:
| Check | Calculation | Result |
|---|---|---|
| Ticket rows | 1,398 clean + 4 quarantined + 14 duplicates | 1,416 = the extract |
| Order value flags | 1,179 + 188 + 24 + 11 | 1,402: the flags are counted after the duplicates are removed and before the quarantine |
| Outcome events | 1,834 clean + 30 repeated + 5 of quarantined tickets | 1,869 = the extract |
The originals are still there. Mei looks at the quarantine file and at the first clean row with each flag:
import pandas as pd
quarantine = pd.read_csv("data/clean/quarantine.csv")columns = ["ticket_id", "created_at", "joined_on", "word_count", "reject_reason"]print(quarantine[columns].to_string(index=False))
clean = pd.read_csv("data/clean/tickets.csv", keep_default_na=False, na_values=[""])columns = ["ticket_id", "team", "order_value_raw", "order_value", "order_value_flag"]first_of_each = clean.drop_duplicates("order_value_flag")print(first_of_each[columns].to_string(index=False))ticket_id created_at joined_on word_count reject_reason T-10361 2026-02-20 07:23:19 2025-11-22 48210 word_count_above_10000 T-11185 2026-03-25 17:00:00 2026-04-05 40 created_before_customer_joined T-11224 2026-05-17 23:00:00 2026-05-24 98 created_before_customer_joined T-11375 2026-03-23 23:00:00 2026-04-10 73 created_before_customer_joinedticket_id team order_value_raw order_value order_value_flag T-10001 returns 47.25 47.25 ok T-10013 warranty 3260.00 32.60 converted_from_cents T-10023 account NaN NaN not_applicable T-10046 returns -1.00 NaN unknowndrop_duplicates("order_value_flag") keeps the first row of each flag value: a quick way to see one example of each. Anyone can now see what changed, check one row by hand, and undo a decision.
The mistake: filling missing values without knowing what they mean
Section titled “The mistake: filling missing values without knowing what they mean”order_value has 199 missing values in the clean table. A common reaction is “fill them with the mean”. Mei shows Omar, the product manager, what that does:
import pandas as pd
clean = pd.read_csv("data/clean/tickets.csv", keep_default_na=False, na_values=[""])value = clean["order_value"]print(f"rows: {len(clean)}, with a value: {value.notna().sum()}, missing: {value.isna().sum()}")print(clean.loc[value.isna(), "order_value_flag"].value_counts())
mean = round(value.mean(), 2)filled = value.fillna(mean)account = clean["team"] == "account"print(f"mean of the real values: {mean}")print(f"account tickets with an order after filling: {filled[account].notna().sum()}")print(f"orders invented for account tickets: {filled[account].sum():.2f} dollars")rows: 1398, with a value: 1199, missing: 199order_value_flagnot_applicable 188unknown 11Name: count, dtype: int64mean of the real values: 123.08account tickets with an order after filling: 188orders invented for account tickets: 23139.04 dollars188 of the 199 missing values are account tickets. They have no order. Filling them gives 188 password resets an order of 123.08 dollars each: 23,139.04 dollars of orders that do not exist. The column now says “every ticket has an order”, which is false, and the difference between account tickets and other tickets is gone. Only the 11 unknown values are real orders with an unknown amount. For them, an estimate is reasonable, with a flag, and computed on training data only.
Common mistake
The missing values are filled, and the data now says something false
Symptom: after a “fill the missing values” step, the column has no missing values, and its average looks normal. But every account ticket has an order of 123.08 dollars, and a report shows 23,139.04 dollars of orders that never existed. Nothing fails.
Cause: the code treated every missing value the same. Here, missing has two meanings: “there is no order” (188 account tickets) and “the amount is unknown” (11 tickets that had -1). The fill is correct for neither the first meaning nor a model that needs to know the difference.
Fix:
- Before you fill a column, group its missing values by a flag or by a related column, such as
team. Ask the data owner what each group means. - Keep “not applicable” values missing, and mark them with a flag such as
order_value_flag = not_applicable. - Impute only values that exist but are unknown. Add an indicator column, so the model knows which values are estimates.
- Learn the estimate (the mean or the median) from the training rows only. Module 5 does this inside the model’s preprocessing.
In AI work: the model learns from the filled values as if they were real. It cannot tell account tickets from small orders, and every number that you report from the column is wrong.
Guided practice: finish clean.py
Section titled “Guided practice: finish clean.py”- Add the constants
CHANNELS,WEB_FORM_FIXEDandMAX_WORDStoscripts/clean.py, aroundTIME_FORMAT. - Add the function
clean_tickets, all three parts, belowload_tickets. - Add the function
clean_outcomes. - Replace the temporary
main()with the new one. - Run
python scripts/clean.py. Check thatclean_rowsis1398for tickets and1834for outcomes. - Open
reports/cleaning.json. It has the same counts as the output. - Make
scripts/show_clean.py, and run it. Check that the quarantine has 4 rows, each with a reason.
If an assert stops the script, the message names the check that failed. Compare your code with the parts above. The most common cause is a missing line in part 1, so a channel spelling stays.
Your turn: one step in a different order
Section titled “Your turn: one step in a different order”In part 2, the cents rule uses the corrected channel. Change it to use channel_raw, the original value:
cents = (df["channel_raw"] == "web_form") & (df["created_at"] < WEB_FORM_FIXED) & df["order_value"].notna()Predict first: how many rows does the rule convert now? Which ticket is different? Run the script, compare the counts with the first run, and then change the line back.
Show the answer
order_value_converted_from_cents is 23, not 24, and order_value_ok is 1180, not 1179. T-10013 has channel_raw = webform, so the rule does not see it. Its order_value stays 3,260 dollars, with the flag ok. Every other count is the same, and the row check still passes: the row check counts rows, not values. This is why the order of the steps is a decision to write down: “correct the channel first, then the units”.
Module practice
Section titled “Module practice”Worked example: seven messy tickets
Section titled “Worked example: seven messy tickets”The outline’s task for this module: clean deliberately messy ticket records, and create a quality summary. Mei shows the method first on seven made-up rows, so every intermediate result is visible. Each row has one problem:
import io
import pandas as pd
MESSY = """ticket_id,team,channel,created_at,joined_on,order_value,word_countT-1,delivery,Email,2026-01-10 09:00:00,2025-06-01,42.5,80T-2,account,chat,2026-03-02 11:30:00,2024-02-11,,45T-3,returns,web_form,2026-03-03 14:00:00,2025-01-20,-1,120T-3,returns,web_form,2026-03-03 14:00:00,2025-01-20,-1,120T-4,payment,,2026-03-04 08:15:00,2026-03-10,64.5,60T-5,warranty,phone,2026-03-05 16:45:00,2023-09-30,210,31000T-6,delivery,web_form,2026-01-20 10:00:00,2025-11-05,4250,95"""
raw = pd.read_csv(io.StringIO(MESSY), dtype={"channel": "string", "order_value": "Float64"}, keep_default_na=False, na_values=[""])raw["created_at"] = pd.to_datetime(raw["created_at"], format="%Y-%m-%d %H:%M:%S")raw["joined_on"] = pd.to_datetime(raw["joined_on"], format="%Y-%m-%d")print(f"1. input: {len(raw)} rows")
df = raw.drop_duplicates()print(f"2. exact duplicates removed: {len(raw) - len(df)} -> {len(df)} rows")
df["channel_raw"] = df["channel"]df["channel"] = df["channel"].str.strip().str.lower().fillna("unknown")
df["order_value_raw"] = df["order_value"]df["order_value_flag"] = "ok"df.loc[df["team"] == "account", "order_value_flag"] = "not_applicable"unknown = df["order_value"] == -1df.loc[unknown, "order_value"] = pd.NAdf.loc[unknown, "order_value_flag"] = "unknown"cents = (df["channel"] == "web_form") & (df["created_at"] < "2026-02-01") & df["order_value"].notna()df.loc[cents, "order_value"] = (df.loc[cents, "order_value"] / 100).round(2)df.loc[cents, "order_value_flag"] = "converted_from_cents"
df["reject_reason"] = pd.NAdf.loc[df["created_at"] < df["joined_on"], "reject_reason"] = "created_before_customer_joined"df.loc[df["word_count"] > 10_000, "reject_reason"] = "word_count_above_10000"print(df[["ticket_id", "channel_raw", "channel", "order_value_raw", "order_value", "order_value_flag", "reject_reason"]].to_string(index=False))
quarantine = df[df["reject_reason"].notna()]clean = df[df["reject_reason"].isna()]print(f"3. quarantined: {len(quarantine)}, clean: {len(clean)}")print(f"4. check: {len(clean)} + {len(quarantine)} + {len(raw) - len(df)} = {len(raw)}")1. input: 7 rows2. exact duplicates removed: 1 -> 6 rowsticket_id channel_raw channel order_value_raw order_value order_value_flag reject_reason T-1 Email email 42.5 42.5 ok <NA> T-2 chat chat <NA> <NA> not_applicable <NA> T-3 web_form web_form -1.0 <NA> unknown <NA> T-4 <NA> unknown 64.5 64.5 ok created_before_customer_joined T-5 phone phone 210.0 210.0 ok word_count_above_10000 T-6 web_form web_form 4250.0 42.5 converted_from_cents <NA>3. quarantined: 2, clean: 44. check: 4 + 2 + 1 = 7Read the table row by row. T-1: the channel is corrected, the original stays. T-2: account, so the missing value stays missing. T-3: the copy is gone, and -1 became a real missing value with the flag unknown. T-4 and T-5 go to quarantine, each with its reason. T-6: a January web form value, divided by 100. The last line accounts for all 7 rows.
Your task: the quality summary
Section titled “Your task: the quality summary”Run scripts/clean.py on the real extract, as in the guided practice. Then write reports/quality.md for Priya, who will train the model. It must answer these questions in short sections:
- How many rows came in, and where did each one go?
- Which values were corrected, how many, and where are the originals?
- Which values are still missing, and what does each kind of missing value mean?
- Which rows are in quarantine, and why?
- What did you assume? Write each assumption that a correction depends on, and who confirmed it.
- What is decided but not done yet?
Show a model answer
# Quality summary: Larkfield tickets after cleaning
Input: data/extract/tickets.csv (1,416 rows) and data/extract/outcomes.csv(1,869 rows), snapshot 2026-07-01 06:00. Script: scripts/clean.py.All counts: reports/cleaning.json.
## Where every row went| Tickets | Rows ||--------------------------------------|-------|| data/clean/tickets.csv | 1,398 || data/clean/quarantine.csv | 4 || exact duplicates removed (counted) | 14 || total = input | 1,416 |
Outcome events: 1,834 clean + 30 repeated events removed (kept the lowestoutcome_id) + 5 events of quarantined tickets = 1,869.
## Corrections (originals kept)- channel: 39 respelled (strip, lower, webform -> web_form). Original: channel_raw.- order_value: 24 web form values before 2026-02-01 divided by 100 (flag converted_from_cents). Original: order_value_raw.- order_value: 11 values of -1 set to missing (flag unknown). Original: order_value_raw.- segment: lower-cased (Trade -> trade).
## Missing values and their meaning- order_value missing, flag not_applicable (188): account tickets, no order. Do not fill.- order_value missing, flag unknown (11): an order exists, amount unknown.- channel "unknown" (38): not recorded.- segment "guest" (12): no account. segment "unknown" (9): deleted customer.- region "unknown": the customer gave no region, or there is no customer row.
## Quarantine (4 rows, data/clean/quarantine.csv)- created_before_customer_joined: T-11185, T-11224, T-11375. One date is wrong; we cannot tell which.- word_count_above_10000: T-10361 (48,210 words, a pasted log file).
## Assumptions behind the corrections- The old web form sent cents until 2026-02-01, and only the web form (Grace).- -1 in order_value means "not found in the order system" (Grace).- Email, EMAIL and " chat" are the same channels as email and chat.- Exact duplicate rows are copies from an export page overlap, not two tickets.- 0 words means an attachment only, and is valid (Grace).
## Decided, not done yet- The 11 unknown order values get the median with an indicator column, learned from the training set only (Module 5).- Grace checks the 4 quarantined tickets by hand.Module check
Section titled “Module check”Check your understanding
Name the three actions for a messy value, and give one Larkfield example of each.
Show the answer
Exclusion: take the row out and keep it in quarantine with a reason, for example T-10361 with 48,210 words. Correction: change a value to the value you know is correct, and keep the original. Example: a January web form value of 4,503 cents → 45.03 dollars. Imputation: replace a missing value with an estimate. Example: the median for the 11 unknown order values, learned in Module 5 from the training rows only. (Removing the 14 exact duplicates is also an exclusion, but it loses no information, so it is counted, not quarantined.)
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
Predict: a colleague adds clean["order_value"] = clean["order_value"].fillna(clean["order_value"].mean()) just before the return in clean_tickets. Does the row check still pass? What is wrong with the result?
Show the answer
The row check passes: it counts rows, and no row is added or removed. But all 199 missing values now have the mean, about 123 dollars. The 188 account tickets get an order that does not exist: about 23,000 dollars of invented orders. The 11 unknown values get an estimate without an indicator column, and the mean includes the rows that will be the test set. The flag column still says not_applicable, so the file now contradicts itself.
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
Completion check: account for every input row of the ticket extract, and name two assumptions behind your corrections.
Show the answer
1,416 input rows = 1,398 clean rows + 4 quarantined rows + 14 exact duplicates removed. The clean rows are in data/clean/tickets.csv. The quarantined rows are in data/clean/quarantine.csv: 3 created_before_customer_joined and 1 word_count_above_10000. The script checks this with an assert, and reports/cleaning.json records each number. Example assumptions: the web form sent cents only before 2026-02-01. -1 means “unknown” in the order system. The duplicate rows are export copies, not separate tickets. Each assumption is in reports/quality.md, with the person who confirmed it.
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
In this module you went from a raw export to a clean table that you can explain:
- Profile first (lesson 1): five questions, every count in
reports/profile.md, and the meaning of each missing value from the person who knows the business. - Load with types (lesson 2):
dtype=,keep_default_na=False, explicit date formats, and joins withvalidate=andindicator=True, so quiet mistakes become errors. - Decide per problem (lesson 3): quarantine with a reason. Correct only with a known rule, and keep the original. Keep meaningful missing values missing, with a flag. Impute later, from training data only.
- Count everything: 1,416 = 1,398 + 4 + 14 tickets, and 1,869 = 1,834 + 30 + 5 events, in
reports/cleaning.jsonandreports/quality.md.
Next, Module 5 asks which columns the model may use. Some columns in the clean table, such as priority_now, are known only after the ticket is created. You define what is known at prediction time, and build the target.