The problem in brief
You receive a table of 426 tumour biopsies, each described by 30 numeric measurements of the cell nuclei seen under a microscope (radius, texture, perimeter, area, smoothness and so on, each given as a mean, a standard error and a "worst" value). The label says whether the tumour is benign (1) or malignant (0). You must hand in a notebook that trains a classifier and exposes a function my_prediction(X_test) returning one label per row of a hidden test table. The twist is the rule: the only supervised model you may use is k-nearest neighbours. Everything else (preprocessing, feature engineering) is free, as long as it stays within scikit-learn, NumPy, pandas and matplotlib.
How you are scored
Predictions on the hidden test set are scored with the macro-averaged F1 score: compute the F1 score of each class, then average the two without weighting by class size,
where and are the precision and recall of class . Because the classes are unbalanced (268 benign, 158 malignant), macro-F1 punishes a model that neglects the smaller, malignant class much more than accuracy would. The grade also depends on the notebook running end to end and on the quality of the written reasoning. No threshold or reference score is published.
The hidden test labels are private, so every number below is measured on the training CSV only: 10 repetitions of stratified 5-fold cross-validation, plus one stratified 25 % hold-out. This data is a version of the well-known Wisconsin Diagnostic Breast Cancer set that ships with scikit-learn. Using that public copy to "check" test predictions would simply mean reading the answers, so we never touch it.
First look at the data
There are no missing values and no duplicates. The labels split 63 % / 37 %. The real surprise comes from the units. Perimeter should be roughly radius, yet here the ratio is about 1030 on every row: the three perimeter columns are stored about 1000 times too large. In the same way, the area columns are about 10 000 times too small (). Whatever the reason (a deliberate trap or a unit mix-up), the result is that the features live on wildly different scales:

That matters enormously for k-NN, as we are about to see.
A first baseline
Out of the box, KNeighborsClassifier(k=5) on the raw columns gives a macro-F1 of 0.912 (10x5-fold CV). That is not bad, but it is misleading: the model is not really using 30 features. The Euclidean distance between two patients is
and a typical difference in "worst perimeter" is tens of thousands, against differences below 1 for most other features. The square root is almost exactly plus a little of the other two perimeter columns: the "30-dimensional" model is in fact a 1-dimensional nearest neighbour on tumour size.
The key idea
k-NN has no weights to learn, so the distance itself is the model. In linear regression, a feature stored in the wrong unit gets its coefficient rescaled during training and nothing is lost. A neighbour classifier cannot do that: each feature counts in proportion to the square of its numeric range. Choosing how to scale the features is therefore choosing how much each feature counts.
The neutral choice is to standardise every column,
with and estimated on the training data only (inside the pipeline, so cross-validation folds never leak). Every feature then has variance 1 and contributes on the same footing. Two nice consequences:
- The unit trap disappears completely. Multiplying a column by 1000 changes and by the same factor, so does not move at all.
- Texture, concavity and concave points, which carry information that size alone does not, finally get their say in the distance.
Once the scale is fixed, the remaining choice is . A small follows every noisy training point (high variance); a large averages over neighbours that are far away and may belong to the other class (high bias). Weighting votes by softens the dependence on .

The picture says it all: no value of rescues the raw features, while any reasonable on standardised features beats them by about 5 points.
The full solution
- Load the CSV, separate the 30 features from
target. - Build
make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=7, weights="distance")). Putting the scaler inside the pipeline guarantees that during cross-validation it is fitted on the training folds only. - Choose and the voting rule by repeated stratified CV (macro-F1), then confirm with a nested CV (grid search inside each outer fold) to measure the cost of tuning. The nested estimate (0.960) is close to the plain one (0.963), so the choice of is not an overfitted lucky pick.
- Refit on all 426 rows and define the inference function:
model = make_pipeline(StandardScaler(),
KNeighborsClassifier(n_neighbors=7, weights="distance")).fit(X, y)
def my_prediction(X_test):
y_pred = model.predict(fix_units(X_test))
return pd.Series(y_pred, index=X_test.index, name="target")
fix_units is a small defensive step: if the hidden test file happened to store perimeters in the usual unit (ratio to near 1 instead of 1000), it converts them to the training units before predicting. With standardisation fitted on training data, a test column in a different unit would otherwise be shifted by thousands of standard deviations. If the test file is in the same units as the training file, as is most likely, the function does nothing. Selecting the columns by name (X_test[COLS]) also protects against a reordered test table.
Results
| approach | macro-F1 (10x5-fold CV) |
|---|---|
| raw features, k = 5 | 0.912 |
| raw features, best k (11) | 0.913 |
| standardised, k = 1 | 0.938 |
| standardised, k = 5 | 0.958 |
| standardised, k = 7, distance weights (submitted) | 0.963 |
| standardised, k = 21 | 0.945 |
| standardised + Manhattan distance, k = 9 | 0.957 |
| log transform + standardise, k = 9 | 0.953 |
| quantile transform to Gaussian, k = 15 | 0.959 |
| standardise + PCA (5 components), k = 9 | 0.958 |
| standardise + PCA (2 components), k = 9 | 0.925 |
| standardise + 10 best features by ANOVA F, k = 9 | 0.932 |
| standardise + forward feature selection (10 features), nested | 0.955 |
| standardise + NCA metric learning, k = 9 | 0.953 |
| nested CV, grid over k, weights, p | 0.960 |
| submitted model on a 25 % stratified hold-out | 0.970 |
The fold-to-fold standard deviation is about 0.02, so every standardised variant between 0.955 and 0.963 is statistically tied. The fancier ideas (feature selection, PCA, learned metrics) did not help: with only 426 rows, each extra choice adds variance, and the 30 standardised features are already informative and not very noisy. Throwing features away hurt the most, because many of them are correlated measurements of the same nuclei and averaging over them makes the distance more stable.
What to remember
- For distance-based models (k-NN, k-means, SVM with RBF kernel), scaling is part of the model. A feature's influence grows with the square of its range; standardise unless you have a reason to weight features differently.
- Always sanity-check units with domain relations (perimeter against radius, area against radius squared). A one-line ratio revealed that three columns were 1000 times too large.
- Fit preprocessing inside the pipeline so cross-validation never sees statistics from the validation fold, and make the inference function robust to column order and unit surprises.
- With small data, report a mean and a spread over repeated CV, and prefer the simplest model inside the noise band: differences of 0.005 in macro-F1 here are noise.
- A public copy of a competition dataset is not a test set. Evaluate on your own held-out split; anything else is reading the answer key.