Poisonous or Edible? A Mushroom Classifier¶
A classification case study where being wrong has a body count¶
This notebook is a portfolio case study by AbleVLabs. It works end to end with the classic UCI Mushroom dataset: 8,124 real mushrooms described by the 1981 Audubon Society field guide, each labeled edible or poisonous, each described by 22 categorical traits like odor, gill size, spore print color, and habitat.
The reason this problem is worth doing is the cost of a mistake. If a spam filter is wrong, an email lands in the wrong folder. If this classifier is wrong in one particular direction, someone eats a poisonous mushroom. That asymmetry is the whole lesson: a model can be 99.9 percent accurate and still be unsafe, and only the confusion matrix, not the accuracy score, will tell you so.
1. The data¶
Every column is categorical, encoded as a single letter in the raw file. The target is
class: e for edible, p for poisonous. There are no numeric columns, so this is a
clean showcase of one-hot encoding rather than scaling or outlier work.
import json, numpy as np, pandas as pd
import matplotlib.pyplot as plt, matplotlib.ticker as mtick
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier, export_text
from sklearn.metrics import (accuracy_score, precision_score, recall_score, f1_score,
confusion_matrix, roc_curve, roc_auc_score)
plt.rcParams.update({'figure.facecolor':'#0d0d12','axes.facecolor':'#12121a',
'savefig.facecolor':'#0d0d12','text.color':'#e8e8ef','axes.labelcolor':'#e8e8ef',
'xtick.color':'#a9a9c0','ytick.color':'#a9a9c0','axes.edgecolor':'#2a2a3a',
'grid.color':'#20202c','font.size':11,'axes.titlecolor':'#e8e8ef','figure.dpi':110})
CY,PU,GR,AM,RED = '#22d3ee','#a855f7','#34d399','#f59e0b','#ef4444'
df = pd.read_csv('mushrooms.csv')
print('shape:', df.shape)
df.head()
shape: (8124, 23)
| class | cap-shape | cap-surface | cap-color | bruises | odor | gill-attachment | gill-spacing | gill-size | gill-color | ... | stalk-surface-below-ring | stalk-color-above-ring | stalk-color-below-ring | veil-type | veil-color | ring-number | ring-type | spore-print-color | population | habitat | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | p | x | s | n | t | p | f | c | n | k | ... | s | w | w | p | w | o | p | k | s | u |
| 1 | e | x | s | y | t | a | f | c | b | k | ... | s | w | w | p | w | o | p | n | n | g |
| 2 | e | b | s | w | t | l | f | c | b | n | ... | s | w | w | p | w | o | p | n | n | m |
| 3 | p | x | y | w | t | p | f | c | n | n | ... | s | w | w | p | w | o | p | k | s | u |
| 4 | e | x | s | g | f | n | f | w | b | k | ... | s | w | w | p | w | o | e | n | a | g |
5 rows × 23 columns
# human-readable decode for the categorical codes (from the UCI data dictionary)
DECODE = json.load(open('mushroom.json'))['decode']
def human(col, code): return DECODE.get(col, {}).get(code, code)
print('class balance:')
print(df['class'].map({'e':'edible','p':'poisonous'}).value_counts())
class balance: class edible 4208 poisonous 3916 Name: count, dtype: int64
2. Cleaning¶
Two things need attention before modeling, and both are judgment calls worth showing.
A constant column. veil-type has the same value for every single mushroom, so it
carries zero information and is dropped.
Missing values. stalk-root has 2,480 entries marked ?. That is almost a third of
the data, so dropping those rows would be wasteful. Instead the ? is kept as its own
category, missing. For a tree-based model this is a clean, honest choice: "we do not
know the stalk root" can itself be predictive.
const_cols = [c for c in df.columns if c!='class' and df[c].nunique()==1]
stalk_missing = int((df['stalk-root']=='?').sum())
print('constant columns dropped:', const_cols)
print("stalk-root '?' kept as its own 'missing' category:", stalk_missing, 'rows')
df2 = df.drop(columns=const_cols)
feat_cols = [c for c in df2.columns if c!='class']
print('features remaining:', len(feat_cols))
constant columns dropped: ['veil-type'] stalk-root '?' kept as its own 'missing' category: 2480 rows features remaining: 21
3. Exploring the strongest signal: odor¶
Before any model, one feature tells most of the story. Grouping by odor and coloring by class shows an almost perfect separation. Every foul, fishy, spicy, pungent, creosote, or musty mushroom in this data is poisonous. Almond and anise are edible. The only overlap is "no odor," which is mostly edible with a small poisonous minority.
def stacked(col, ax):
g = df.groupby([col,'class']).size().unstack(fill_value=0)
labels = [human(col,c) for c in g.index]
e = g.get('e', pd.Series(0,index=g.index)).values
p = g.get('p', pd.Series(0,index=g.index)).values
order = np.argsort(-(e+p))
labels=[labels[i] for i in order]; e=e[order]; p=p[order]
ax.bar(labels, e, color=GR, label='edible')
ax.bar(labels, p, bottom=e, color=RED, label='poisonous')
ax.set_title(f'{col} vs class'); ax.tick_params(axis='x', rotation=40)
ax.legend(facecolor='#12121a', edgecolor='#2a2a3a', labelcolor='#e8e8ef', fontsize=9)
fig, ax = plt.subplots(figsize=(9,4.4)); stacked('odor', ax); plt.tight_layout(); plt.show()
# three more telling features
fig, axes = plt.subplots(1,3, figsize=(13,3.8))
for a,c in zip(axes, ['spore-print-color','gill-size','bruises']): stacked(c,a)
plt.tight_layout(); plt.show()
4. Encoding and the train/test split¶
Every feature is categorical, so each one is expanded into 0/1 indicator columns with one-hot encoding. That turns 21 columns into 116. The data is then split 70/30 with stratification so the class balance is preserved in both halves. The model is only ever evaluated on the held-out 30 percent it never saw during training.
y = (df2['class']=='p').astype(int).values # poisonous = 1 (the positive class)
X = pd.get_dummies(df2[feat_cols], prefix_sep='=')
print('one-hot features:', X.shape[1])
Xtr,Xte,ytr,yte = train_test_split(X.values, y, test_size=0.30,
random_state=42, stratify=y)
print('train:', len(ytr), ' test:', len(yte))
one-hot features: 116 train: 5686 test: 2438
5. Two models¶
A logistic regression baseline and a shallow decision tree. The tree is capped at depth 5 on purpose: a readable set of rules is worth more here than a fractionally better score, and it doubles as the engine behind the interactive predictor on the dashboard.
def evaluate(name, model):
model.fit(Xtr,ytr)
proba = model.predict_proba(Xte)[:,1]
pred = (proba>=0.5).astype(int)
tn,fp,fn,tp = confusion_matrix(yte,pred).ravel()
row = dict(model=name,
accuracy=round(accuracy_score(yte,pred),4),
precision=round(precision_score(yte,pred),4),
recall=round(recall_score(yte,pred),4),
f1=round(f1_score(yte,pred),4),
AUC=round(roc_auc_score(yte,proba),4),
false_neg=int(fn))
return row, proba, (tn,fp,fn,tp)
lr = LogisticRegression(max_iter=2000)
tree = DecisionTreeClassifier(max_depth=5, random_state=42)
lr_row, lr_p, lr_cm = evaluate('Logistic Regression', lr)
tr_row, tr_p, tr_cm = evaluate('Decision Tree (depth 5)', tree)
pd.DataFrame([lr_row, tr_row]).set_index('model')
| accuracy | precision | recall | f1 | AUC | false_neg | |
|---|---|---|---|---|---|---|
| model | ||||||
| Logistic Regression | 0.9992 | 1.0 | 0.9983 | 0.9991 | 1.0000 | 2 |
| Decision Tree (depth 5) | 0.9988 | 1.0 | 0.9974 | 0.9987 | 0.9999 | 3 |
Both models score above 99.8 percent on every headline metric. If the story ended here it would be a bad story. The next cell is where a careful analyst earns their keep.
6. Why accuracy is the wrong headline¶
The confusion matrix splits the errors into two very different kinds. A false positive is an edible mushroom flagged as poisonous: annoying, you throw away a good mushroom. A false negative is a poisonous mushroom labeled edible: potentially fatal.
Both models here make zero false positives and a handful of false negatives. In other words, every mistake the model makes is the dangerous kind. Accuracy of 99.9 percent hid that completely.
def plot_cm(cm, title, ax):
tn,fp,fn,tp = cm
M = np.array([[tn,fp],[fn,tp]])
ax.imshow(M, cmap='magma')
labels=[['TN','FP (edible flagged)'],['FN (POISON MISSED)','TP']]
for i in range(2):
for j in range(2):
c = RED if (i==1 and j==0 and M[i,j]>0) else '#e8e8ef'
ax.text(j,i,f'{labels[i][j]}\n{M[i,j]}',ha='center',va='center',color=c,fontsize=10,fontweight='bold')
ax.set_xticks([0,1]); ax.set_xticklabels(['pred edible','pred poison'])
ax.set_yticks([0,1]); ax.set_yticklabels(['actual edible','actual poison'])
ax.set_title(title)
fig, axes = plt.subplots(1,2, figsize=(11,4))
plot_cm(lr_cm,'Logistic Regression', axes[0])
plot_cm(tr_cm,'Decision Tree', axes[1])
plt.tight_layout(); plt.show()
print(f'Logistic Regression let {lr_cm[2]} poisonous mushrooms through as edible.')
Logistic Regression let 2 poisonous mushrooms through as edible.
7. ROC, AUC, and tuning the threshold to be safe¶
The ROC curve and its area (AUC) confirm the models rank poison above edible almost perfectly. But the default 0.50 cutoff is a business decision, not a law of nature. Since a missed poison is the error that matters, the threshold can be lowered so the model errs toward caution: flag anything with even a modest probability of being poisonous.
fig, ax = plt.subplots(figsize=(6.2,5.2))
for proba,name,col in [(lr_p,'Logistic Regression',CY),(tr_p,'Decision Tree',PU)]:
fpr,tpr,_ = roc_curve(yte,proba)
ax.plot(fpr,tpr,color=col,lw=2,label=f'{name} (AUC {roc_auc_score(yte,proba):.3f})')
ax.plot([0,1],[0,1],'--',color='#5a6480',lw=1)
ax.set_xlabel('false positive rate'); ax.set_ylabel('true positive rate')
ax.set_title('ROC curve'); ax.legend(facecolor='#12121a',edgecolor='#2a2a3a',labelcolor='#e8e8ef')
plt.tight_layout(); plt.show()
# lowest threshold with zero missed poison
for thr in np.linspace(0.5,0.001,500):
tn,fp,fn,tp = confusion_matrix(yte,(lr_p>=thr).astype(int)).ravel()
if fn==0:
print(f'Lowering the cutoff to {thr:.3f} catches every poisonous mushroom '
f'(0 false negatives) with precision {tp/(tp+fp):.3f}.')
break
Lowering the cutoff to 0.387 catches every poisonous mushroom (0 false negatives) with precision 1.000.
8. What the model learned¶
Feature importance from the tree confirms the intuition from the exploration step: odor dominates, with a supporting cast of spore print color, stalk traits, and ring type. The tree's top rules read almost like a foraging heuristic.
imp = sorted(zip(X.columns, tree.feature_importances_), key=lambda t:-t[1])
top = [(f"{c.split('=')[0]} = {human(c.split('=')[0], c.split('=',1)[1])}", round(v,3))
for c,v in imp[:8] if v>0]
fig, ax = plt.subplots(figsize=(8,3.8))
labs=[t[0] for t in top][::-1]; vals=[t[1] for t in top][::-1]
ax.barh(labs, vals, color=AM); ax.set_title('Top feature importances (decision tree)')
ax.set_xlabel('importance'); plt.tight_layout(); plt.show()
print('First rules of the tree:')
print('\n'.join(export_text(tree, feature_names=list(X.columns)).splitlines()[:14]))
First rules of the tree: |--- odor=n <= 0.50 | |--- stalk-root=c <= 0.50 | | |--- stalk-surface-below-ring=y <= 0.50 | | | |--- odor=a <= 0.50 | | | | |--- odor=l <= 0.50 | | | | | |--- class: 1 | | | | |--- odor=l > 0.50 | | | | | |--- class: 0 | | | |--- odor=a > 0.50 | | | | |--- class: 0 | | |--- stalk-surface-below-ring=y > 0.50 | | | |--- class: 0 | |--- stalk-root=c > 0.50 | | |--- ring-type=p <= 0.50
9. Conclusions and limits¶
What holds up.
- The problem is nearly separable: two well-chosen features (odor, spore print color) already carry most of the signal, and both models exceed 99.8 percent accuracy.
- The important finding is not the accuracy, it is the shape of the errors. Every mistake was a missed poison, which is why the confusion matrix, precision, recall, and a tuned threshold matter more than a single accuracy number.
- One-hot encoding plus a shallow, interpretable tree gives a model whose rules a human can read and trust.
What this is not.
- This is a clean, curated dataset from a field guide. Real foraging is far messier: lighting, decay, regional variants, and human error all degrade the signal.
- No one should ever eat a wild mushroom based on a model. The value here is the method, not a foraging tool.
Why it matters. Fraud detection, disease screening, and safety alerts share this exact shape: rare, high-cost errors where accuracy is a trap and the confusion matrix is the truth. The subject is playful. The reasoning transfers directly.
Data: UCI Machine Learning Repository (Mushroom). Analysis and visuals by AbleVLabs.