Project: a price model
Multiple regression on log prices, one-hot categories, percentage effects and an extrapolation flag.
Create a free account Try part of it here
Why it matters
Pricing models are everywhere: house valuations, insurance quotes, used-car offers, cloud cost estimates. They're also a clean way to learn what a regression really says: how much each feature moves the answer, how sure you can be, and where the model's knowledge runs out. The log-scale and one-hot tricks in this project come up in almost every tabular model you'll build, including the neural networks in Phase 4.
What you'll be able to do
- Fit a multiple regression on several features, and judge it on held-out data with R² and dollar errors
- Model a price on a log scale, and read its coefficients as percentage effects
- Turn categories into numbers with one-hot encoding, and keep the columns consistent at prediction time
- Flag a quote whose inputs fall outside the data the model learned from
What it covers
- The brief
- A first model, in dollars
- Why prices want a log scale
- Categories as numbers: one-hot encoding
- Columns at prediction time
- The log model
- Where the model's knowledge ends
- Worked example: quoting three items
The brief
The practice shop is starting a buyback desk: customers bring in used gear, and the shop buys it to resell. Staff need an instant, consistent quote. You have 800 past trade-ins, each with what the item later sold for:
import pandas as pd trade = pd.read_csv("https://api.gradient.test/v1/datasets/trade-ins.csv") print(trade.head()) print(trade[["age_years", "condition", "original_price", "sale_price"]].describe().round(1)) print(trade["item_type"].value_counts().to_dict())
The target is sale_price, the number to predict. The features are the item's type, its brand tier (budget, mid or premium), its age in years, its condition from 1 (poor) to 5 (like new), and what it cost new. Most items are young: half are two years old or less, and only six are older than ten. Remember that; it matters at the end.
A first model, in dollars
Start simple: a linear regression on the three number columns, trained on 75% of the rows and judged on the 25% it never saw.
import pandas as pd from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_absolute_error, r2_score from sklearn.model_selection import train_test_split trade = pd.read_csv("https://api.gradient.test/v1/datasets/trade-ins.csv") train, test = train_test_split(trade, test_size=0.25, random_state=0) cols = ["original_price", "age_years", "condition"] model = LinearRegression().fit(train[cols], train["sale_price"]) pred = model.predict(test[cols]) print("R² on test:", round(r2_score(test["sale_price"], pred), 3)) print("typical error: $", round(mean_absolute_error(test["sale_price"], pred), 1)) print(dict(zip(cols, model.coef_.round(2).tolist())), round(model.intercept_, 2)) print("negative quotes on the whole table:", (model.predict(trade[cols]) < 0).sum())
R² is the share of the variation in sale prices that the model explains: 1 would be perfect, 0 no better than always guessing the average. The mean absolute error (MAE) is the typical miss in dollars, which is what the desk cares about. 0.83 and $17 aren't bad. But the coefficients say every year knocks a flat $5.94 off any item, a $40 stove or a $500 tent alike, and nine items get a negative price. Something about the model's shape is wrong.
Why prices want a log scale
Used gear doesn't lose a fixed number of dollars a year. It loses a share of its value: a premium tent and a budget stove might both lose about 8% a year, which is $30 for one and $4 for the other. Effects like that are multiplicative, and taking the logarithm turns multiplication into addition, which is exactly what a linear model does:
$$ \log(\text{price}) = b_0 + b_1 \cdot \text{age} + b_2 \cdot \text{condition} + \dots $$
Each coefficient now changes the log of the price. Undo the log and a coefficient multiplies the price by for every one-unit step. So the percentage effect is .
In plain words: fit the model to log prices, then read each coefficient as "this many percent per step". A coefficient of -0.08 on age means each extra year multiplies the price by , about 8% less, whatever the item.
Two more wins: is always positive, so negative quotes become impossible. And log the original price too, so the model learns "a share of what it cost new" instead of a flat dollar amount.
import math print(round((math.exp(0.12) - 1) * 100, 1))
Show the answer
12.7
Each condition point multiplies the price by e^0.12 ≈ 1.127: about 12.7% more. For small coefficients the percentage is close to b × 100.
Categories as numbers: one-hot encoding
A regression needs numbers, but item type is a word. One-hot encoding gives each category its own 0/1 column. pd.get_dummies does it, and drop_first=True leaves one category out as the baseline that the others are compared with:
import pandas as pd items = pd.DataFrame({"item_type": ["tent", "stove", "boots", "tent"], "brand_tier": ["mid", "budget", "premium", "premium"]}) print(pd.get_dummies(items, drop_first=True, dtype=float))
Boots and budget come first alphabetically, so they're dropped and become the baseline. A row of all zeros is budget boots. The coefficient on brand_tier_premium then says how much more a premium item fetches than an otherwise identical budget one.
Why drop one? With every category's column present, the columns always add up to 1, which is the same information as the intercept. The model can't then tell the two apart, and its coefficients stop meaning anything. Dropping one column per feature fixes that.
Two traps wait at prediction time. First, a single new item, say a mid-tier stove, makes only the columns for its categories, and the trained model expects every column, in the same order. Second, drop_first=True on that one row would drop the stove's own column, since it's the row's first and only category, and the model would quietly price it as boots. So for new rows, make every dummy column without dropping, then line them up with X.reindex(columns=train_columns, fill_value=0). Columns the model never had (like the baseline's) are thrown away, missing ones become 0, and the order matches.
Columns at prediction time
train_cols holds the columns the model was trained on. Turn the single new item into a one-row table with exactly those columns, in that order (missing ones as 0), and store it in row.
import numpy as np import pandas as pd train_cols = ["log_original", "age_years", "condition", "item_type_jacket", "item_type_pack", "item_type_stove", "item_type_tent", "brand_tier_mid", "brand_tier_premium"] item = {"item_type": "stove", "brand_tier": "mid", "age_years": 3, "condition": 4, "original_price": 95} row = None
The log model
Put it together: log target, log original price, age, condition and the one-hot categories.
import numpy as np import pandas as pd from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_absolute_error, r2_score from sklearn.model_selection import train_test_split trade = pd.read_csv("https://api.gradient.test/v1/datasets/trade-ins.csv") train, test = train_test_split(trade, test_size=0.25, random_state=0) def features(df, drop_first=True): X = pd.DataFrame({"log_original": np.log(df["original_price"]), "age_years": df["age_years"], "condition": df["condition"]}) return X.join(pd.get_dummies(df[["item_type", "brand_tier"]], drop_first=drop_first, dtype=float)) X_train = features(train) model = LinearRegression().fit(X_train, np.log(train["sale_price"])) pred = np.exp(model.predict(features(test, drop_first=False).reindex(columns=X_train.columns, fill_value=0))) print("R² on test:", round(r2_score(test["sale_price"], pred), 3)) print("typical error: $", round(mean_absolute_error(test["sale_price"], pred), 1)) for name, b in zip(X_train.columns, model.coef_): effect = "" if name == "log_original" else f"{(np.exp(b) - 1) * 100:6.1f}% per step" print(f"{name:20} {b:7.3f} {effect}")
Test R² rises to about 0.87 and the typical miss falls to about $14. Every quote is positive. The coefficients now read naturally: about −7.6% per year of age and +12.9% per condition point. The log of the original price has a coefficient near 1, so the sale price is roughly proportional to what the item cost new.
Look at the categories: once the original price is in the model, item type and brand tier barely matter. Their coefficients sit within a few percent of zero. That doesn't mean brands don't matter. A premium tent sells for far more than a budget one. But the original price already carries that information. Drop log_original from the model, and premium jumps to about +120%. A coefficient always means "holding the other features fixed", so it depends on what else is in the model.
Each year multiplies the quote by about 0.92, so the dollar loss per year shrinks as the item ages, and an expensive item loses more dollars than a cheap one. That's the multiplicative pattern the log model captures and the dollar model couldn't.
Where the model's knowledge ends
A regression will happily price a 25-year-old tent: it just follows the line. But only six of the 800 trade-ins are older than ten, and none older than 12. Beyond that, the model is guessing. It assumes the 8%-a-year pattern carries on forever, when vintage gear might be worth more, or nothing. Predicting outside the range of the training data is called extrapolation, and a good tool says when it's doing it.
The simplest guard is to record each number feature's range in the training data and flag any quote that steps outside it:
ranges = {c: (train[c].min(), train[c].max()) for c in ["age_years", "condition", "original_price"]}
outside = [c for c, (lo, hi) in ranges.items() if not lo <= item[c] <= hi]
A flagged quote isn't necessarily wrong. It means a person should look before money changes hands. Categories need the same care: an item type the model never saw (say, kayaks) has no column at all, and reindex would quietly treat it as the baseline.
Worked example: quoting three items
Fit the log model on all 800 trade-ins (the test set has done its job) and quote three items, one of them unusual.
import numpy as np import pandas as pd from sklearn.linear_model import LinearRegression trade = pd.read_csv("https://api.gradient.test/v1/datasets/trade-ins.csv") def features(df, drop_first=True): X = pd.DataFrame({"log_original": np.log(df["original_price"]), "age_years": df["age_years"], "condition": df["condition"]}) return X.join(pd.get_dummies(df[["item_type", "brand_tier"]], drop_first=drop_first, dtype=float)) X = features(trade) model = LinearRegression().fit(X, np.log(trade["sale_price"])) ranges = {c: (trade[c].min(), trade[c].max()) for c in ["age_years", "condition", "original_price"]} items = pd.DataFrame([ {"item_type": "tent", "brand_tier": "premium", "age_years": 2, "condition": 4, "original_price": 420}, {"item_type": "stove", "brand_tier": "budget", "age_years": 6, "condition": 3, "original_price": 60}, {"item_type": "jacket", "brand_tier": "mid", "age_years": 18, "condition": 2, "original_price": 190}, ]) quotes = np.exp(model.predict(features(items, drop_first=False).reindex(columns=X.columns, fill_value=0))) for (_, item), q in zip(items.iterrows(), quotes): outside = [c for c, (lo, hi) in ranges.items() if not lo <= item[c] <= hi] note = f" check by hand: {', '.join(outside)} outside training data" if outside else "" print(f"{item['brand_tier']:8} {item['item_type']:7} ${q:6.0f}{note}")
Once you've picked the model on a held-out test set, it's normal to refit on all the data before using it: more examples, slightly better coefficients. The quotes come from np.exp of the predicted log price. The 18-year-old jacket still gets a number, because the formula always produces one. The range check is what tells staff not to trust it blindly. One more subtlety: exp of an average log is a little below the average price, roughly the typical (median) item rather than the mean. For a quote that's fine, since the shop wants a fair, typical offer.
Practice what you just read
- 6 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
In a log-price model the age coefficient is -0.08. What does that mean?