

Overview
Every young sprinter is told the same two things, usually by different people: that early results decide everything, and that they mean nothing at all. Almost nobody has checked.
This is a retrospective cohort study, run with Università di Roma Foro Italico on the database rebuilt from twenty years of FIDAL rankings — around 450,000 registered athletes, 2005 to 2025. Seven birth cohorts, 1989 to 1995, are followed from their Allievi (U18) debut to age 30 over 100 m and 200 m, with the FIDAL scoring tables turning times into one comparable number across events and eras.
The blunt finding so far: the exit door is at 17. The steepest attrition of the whole career is the step out of U18, and it hits every performance level. Being fast early helps — the top Allievi quintile is retained far better than the bottom one — but it is not protection: most of the fastest sixteen-year-olds are also gone before the senior years.
What we built
- A cohort definition with per-athlete observation windows, so every career is compared over the same phases: Allievi, Juniores, Promesse, Seniores.
- Retention and dropout measured at every transition, both cumulatively and conditionally, and split by Allievi performance quintile.
- Real dropout separated from specific dropout: about 64.5% of the men who stop sprinting leave athletics altogether, while the rest turn up in the 400 m or the horizontal jumps.
- Quintile mobility from U18 to senior as a transition matrix, with ANOVA at each phase: the U18 ranking never stops being significant, but its F-statistic collapses from 1140 to 99 as careers diverge.
- Birth-quarter composition by quintile, where the relative age effect shows up: 65.1% of the top male quintile is born in the first half of the year.
- K-means over the four-phase score matrix, which finds six career archetypes — early dropout, late bloomer, and the four in between — projected with PCA.
Inside the study
Read-only · outputs from studio_coorte_sprint_italiano_89_95.ipynb
Five cells from the cohort notebook: how the seven birth cohorts are framed in time, what retention looks like once athletes are split by their U18 level, and the six career archetypes k-means pulls out of the score matrix.
COHORTS = list(range(1989, 1996)) # seven birth years
DATA_START = 2005
DATA_END = 2025
AGE_SEN_MAX = 30 # senior window capped at 30
def cat_windows(birth_year: int) -> dict:
"""Observable category windows, clipped to the years we actually have."""
raw = {
"Allievi": (birth_year + 16, birth_year + 17),
"Juniores": (birth_year + 18, birth_year + 19),
"Promesse": (birth_year + 20, birth_year + 22),
"Seniores": (birth_year + 23, birth_year + AGE_SEN_MAX),
}
return {c: (max(y0, DATA_START), min(y1, DATA_END))
for c, (y0, y1) in raw.items() if max(y0, DATA_START) <= min(y1, DATA_END)}# Best Allievi score per athlete, then split each sex into quintiles
_al = (df_wide[df_wide["score_Allievi"].notna()]
.groupby(["athlete_id", "gender"])["score_Allievi"].max()
.reset_index().rename(columns={"score_Allievi": "allievi_score"}))
Q_LABELS = ["Q1 (lowest)", "Q2", "Q3", "Q4", "Q5 (highest)"]
_al["quintile"] = _al.groupby("gender")["allievi_score"].transform(
lambda x: pd.qcut(x, q=5, labels=Q_LABELS)
)
…
plot_retention_by_quintile(_al, df_wide)# Dropout at every transition, as a share of the athletes still there
drop = (1 - retained / previous) * 100
for gender, label in [("M", "Males"), ("F", "Females")]:
show(drop.loc[gender], caption=f"Dropout (cumulative) — {label}")km_final = KMeans(n_clusters=K_BEST, random_state=RANDOM_STATE, n_init=30)
feat["cluster"] = km_final.fit_predict(X_scaled)
sizes = feat["cluster"].value_counts().sort_index()
print("Cluster sizes:")
for c, n in sizes.items():
print(f" Cluster {c}: {n:>5,} athletes ({n / len(feat) * 100:.1f}%)")traj = feat.groupby("cluster")[SCORE_COLS].mean()
for c in range(K_BEST):
ax.plot(x, traj.loc[c].values, marker="o", linewidth=2.2,
color=CLUSTER_PALETTE[c], label=f"Cluster {c} (n={sizes[c]:,})")
ax.set_xticklabels(["Allievi", "Juniores", "Promesse", "Seniores"])
ax.set_ylabel("Mean FIDAL score")
plt.show()pca = PCA(n_components=2, random_state=RANDOM_STATE)
X_pca = pca.fit_transform(X_scaled)
var = pca.explained_variance_ratio_ * 100
print(f"Variance explained — PC1: {var[0]:.1f}% | PC2: {var[1]:.1f}% | Total: {var.sum():.1f}%")