The problem in brief
A bank gives you one row per customer and per month (January to August): age, occupation, income, salary, number of accounts, cards and loans, payment delays, debt, credit history length, payment behaviour, and so on. The target is the customer's credit score: poor (), standard () or good (). Four warm-up questions ask for simple statistics on the training table (44,207 rows), then you must predict the score of the 8,841 test rows. The data is famously dirty: numbers with stray underscores ("45_"), placeholders ("10000", "_", "NM"), impossible ages, garbled social security numbers.
How you are scored
Subtasks 1 to 4 are exact answers worth 4, 5, 5 and 6 points. Subtask 5 is scored by accuracy in steps: below 0.4 nothing, then 10, 25, 55 and 65 points for , , , , and the full 80 points for accuracy (statement, "Scoring"). Test labels are private: we validate on a held-out 20 % of the training rows.
Subtasks 1 to 4
| subtask | question | answer |
|---|---|---|
| 1 | number of rows of train_data.csv | 44207 |
| 2 | floor of the mean Monthly_Inhand_Salary over rows with Credit_Utilization_Ratio | 4106 |
| 3 | number of distinct Month values | 8 |
| 4 | number of distinct SSN values ending in "20" | 109 |
For subtask 2, both columns are clean floats with no missing value (40,458 rows qualify), and "floor" means rounding down, so int(np.floor(...)). For subtask 4, compare strings: ssn.str.endswith("20"), then count unique values (a customer appears in several months with the same SSN). The garbage SSNs such as #F%$D@*&8 never end in "20", so they need no special handling.
First look at the data
Two observations shape the whole solution.
The split is by row, not by customer. Each customer has up to 8 monthly rows, and the test rows were drawn at random among them: 99.0 % of the test rows belong to customers who also have rows in the training set, with their labels.
A customer's score is very stable. Take every pair of training rows of the same customer and check whether their scores are equal:

Consecutive months agree 83 % of the time, against 40 % for two random customers. So for a test row, the labels of the same customer in nearby months are a very strong hint, and they are legitimately available: they are part of the training data.
The dirt is mostly per row: a customer whose age is 34 in seven months shows "844_" in the eighth, or 1,500 bank accounts once. Since the true value of these attributes is constant (or slowly varying) for one customer, the median over the customer's rows is a robust fix.
A first baseline
Predicting the majority class (standard) gives 0.526 accuracy (25 points). Gradient-boosted trees (HistGradientBoostingClassifier) on the cleaned row features alone reach 0.752, already just above the full-score line, but with no margin.
The key idea
Two pieces of information live at the customer level rather than in the row:
- Customer medians of each numeric attribute. They undo the random corruption of individual rows. This lifts accuracy to 0.777.
- The labels of the customer's other months. For each row we compute, from the other labelled rows of the same customer: how many were poor, standard and good, their mean, and the label of the nearest month (and how far it is). This is a form of target encoding at the customer level, and it brings the accuracy to 0.828.
Is this a leak? No: the test rows' customers really do have labelled months in the training file, and the statement does not forbid using them. But the validation must reproduce the situation honestly. For the held-out 20 %, the label features are computed only from the other 80 %, and for training rows the row's own label is always excluded (otherwise the model would learn "the answer is in the feature" and fail at test time). At test time, the features use all training rows, so the test should be at least as good as our validation.
The full solution
- Clean: strip underscores and convert to numbers; set impossible ages and the "10000" investment placeholder to missing; turn "13 Years and 10 Months" into 166 months; encode months, occupations, credit mix, minimum-payment flag and payment behaviour as categories; turn the loan list into 8 indicator columns.
- Customer medians of every numeric column, and the customer's most frequent known credit mix.
- Customer label features (leave-one-out, from labelled rows only): counts per class, mean, nearest-month label and distance.
- Model:
HistGradientBoostingClassifier(max_iter=400, learning_rate=0.06), which handles the remaining missing values natively.
F["cust_n"] = cust.map(K.groupby("cust").size()).fillna(0) - own.notna() # exclude the row itself
F["cust_mean"] = (cust.map(K.groupby("cust").y.sum()).fillna(0) - own.fillna(0)) / F["cust_n"]
Results
Accuracy on a random 20 % of the training rows (8,841 rows, like the test):
| approach | accuracy | points / 80 |
|---|---|---|
| majority class | 0.526 | 25 |
| label of the customer's nearest labelled month (97 % coverage) | 0.775 | 80 |
| gradient boosting, cleaned row features | 0.752 | 80 |
| + customer medians of the features | 0.777 | 80 |
| + labels of the customer's other months (final) | 0.828 | 80 |
With subtasks 1 to 4 correct, the expected total is 100 points. Note how strong the one-line rule "copy the nearest month's label" is (0.775): it beats a full model on the row features, a sign that the structure of the data matters more than the algorithm.
What to remember
- Check how train and test were split. A row-level split of panel data (several rows per entity) means the same entities appear on both sides; their other rows are then the most valuable information you have.
- Target encoding must be leave-one-out, and the validation must compute it from the training part only, exactly as at test time.
- Fix dirty values at the level where the truth is constant: a per-customer median repairs corrupted ages or account counts better than any global imputation.
- Compare with simple rules: "same as last month" is a baseline any model must beat.
- For warm-up questions, read them literally: string matching for "ends with 20", unique values when asked for unique values, floor when asked for the integer part.