#!/usr/bin/env python3 """The zodiac signs of professional athletes, and the youth-sport cutoff dates that set them. Source of the numbers on https://startarot.online/research/zodiac-signs-of-athletes Needs Python 3.10+ and pip install numpy pandas pyreadr The raw files (about 300 MB) go to $ATHLETE_SIGNS_DATA (default: ./athlete-signs-data next to this script). Steps, in order: python3 athlete_birth_signs.py fetch # Wikidata (QLever), Lahman, official births by month python3 athlete_birth_signs.py compute # people, baselines, signs, cutoffs, tests python3 athlete_birth_signs.py validate # dates, coverage, Lahman against Wikidata, baselines python3 athlete_birth_signs.py export # page JSON + dataset CSV (+ a copy of this script) Run from the repo, export writes (GENERATED — regenerate, do not hand-edit): frontend/src/lib/research/athlete-birth-signs.json data the page renders frontend/static/research/athlete-birth-signs.csv the open dataset (observed and expected counts by group, cohort and month/sign) frontend/static/research/athlete_birth_signs.py a copy of this script A copy downloaded from the page writes the JSON and CSV next to itself. Individual people are not published: the dataset holds counts only. Pre-registration ---------------- A first look (28.10.2026) covered men's football in Germany, the Netherlands, the UK, Japan, France, Brazil, Belgium, Italy, Spain and Russia, baseball in the USA and Japan and ice hockey in Canada and Russia, without a baseline. Every other cell was unopened when ``PREDICTIONS`` below was written and committed (28.10.2026, before ``compute`` existed); the page prints every prediction with its result, the misses too. A cutoff date comes from a federation rule or a published relative-age study (``CUTOFF_SOURCES``); "unknown" cells get no prediction. * Directional: the cell's quarter right after the cutoff (the oldest children of a cohort) is born more often than its quarter right before the cutoff (the youngest), against the official baseline: ratio > 1 with a one-sided z > 1.645. * Sign: the most over-represented sign is one of the three signs whose days lie mostly in the three months after the cutoff. * Weaker: a cell named "weaker than X" has a quarter ratio closer to 1 than X. * Flat: famous people outside sport (politicians, writers, actors, painters, musicians, researchers) show no cutoff: their ratio for a 1 January and a 1 September cutoff stays within 0.95–1.05. This is also the test of the baseline itself. """ from __future__ import annotations import csv import gzip import hashlib import io import json import math import os import shutil import subprocess import sys import time import urllib.parse import urllib.request import zipfile from datetime import date, timedelta from pathlib import Path HERE = Path(__file__).resolve().parent REPO_ROOT = HERE.parents[1] if len(HERE.parents) > 1 else HERE FILE_STEM = 'athlete-birth-signs' if (REPO_ROOT / 'frontend' / 'src' / 'lib' / 'research').is_dir(): JSON_OUT = REPO_ROOT / 'frontend' / 'src' / 'lib' / 'research' / f'{FILE_STEM}.json' CSV_OUT = REPO_ROOT / 'frontend' / 'static' / 'research' / f'{FILE_STEM}.csv' SCRIPT_COPY = REPO_ROOT / 'frontend' / 'static' / 'research' / 'athlete_birth_signs.py' else: JSON_OUT = HERE / f'{FILE_STEM}.json' CSV_OUT = HERE / f'{FILE_STEM}.csv' SCRIPT_COPY = None DATA = Path(os.environ.get('ATHLETE_SIGNS_DATA', HERE / 'athlete-signs-data')) RAW = DATA / 'raw' QLEVER = 'https://qlever.dev/api/wikidata' def log(*a): print(*a, file=sys.stderr, flush=True) # ------------------------------------------------------------------ groups # Wikidata occupations (P106). A person with several occupations counts in each of them. SPORTS = { 'football': ('Q937857', 'Association football'), 'basketball': ('Q3665646', 'Basketball'), 'ice_hockey': ('Q11774891', 'Ice hockey'), 'baseball': ('Q10871364', 'Baseball'), 'american_football': ('Q19204627', 'American football'), 'cricket': ('Q12299841', 'Cricket'), 'rugby_union': ('Q14089670', 'Rugby union'), 'rugby_league': ('Q14373094', 'Rugby league'), 'australian_rules': ('Q13414980', 'Australian rules football'), 'handball': ('Q12840545', 'Handball'), 'volleyball': ('Q15117302', 'Volleyball'), 'tennis': ('Q10833314', 'Tennis'), 'athletics': ('Q11513337', 'Athletics'), 'swimming': ('Q10843402', 'Swimming'), 'cycling': ('Q2309784', 'Cycling'), 'field_hockey': ('Q10843263', 'Field hockey'), 'boxing': ('Q11338576', 'Boxing'), 'badminton': ('Q13141064', 'Badminton'), 'rowing': ('Q13382576', 'Rowing'), 'chess': ('Q10873124', 'Chess'), 'golf': ('Q11303721', 'Golf'), 'jockey': ('Q846750', 'Horse racing (jockeys)'), 'snooker': ('Q17165321', 'Snooker'), 'darts': ('Q18574233', 'Darts'), 'formula_one': ('Q10841764', 'Formula One'), } # Famous people who are not athletes: the second baseline (anyone with a sports occupation above is # dropped from it). Same source, same date errors, same coverage by country and era as the athletes. OTHERS = { 'politician': ('Q82955', 'Politicians'), 'writer': ('Q36180', 'Writers'), 'actor': ('Q33999', 'Actors'), 'painter': ('Q1028181', 'Painters'), 'musician': ('Q639669', 'Musicians'), 'researcher': ('Q1650915', 'Researchers'), } GROUPS = {**SPORTS, **OTHERS} # Constituent countries of the UK: their football and rugby have their own youth systems. UK_PARTS = {'Q21': 'GB-ENG', 'Q22': 'GB-SCT', 'Q25': 'GB-WLS', 'Q26': 'GB-NIR'} # Historical states -> the ISO code of the successor whose births series covers the place # (birthplace country is used first; these only matter when it is missing). HISTORICAL = {'Q713750': 'DE', 'Q16957': 'DE', 'Q15180': 'SU', 'Q83286': 'YU', 'Q33946': 'CS'} PREFIX = '''PREFIX wd: PREFIX wdt: PREFIX p: PREFIX psv: PREFIX wikibase: ''' Q_PEOPLE = PREFIX + '''SELECT ?h ?t ?prec ?sex ?links WHERE { ?h wdt:P106 wd:%s . ?h wdt:P31 wd:Q5 . ?h p:P569 ?st . ?st psv:P569 ?v . ?v wikibase:timeValue ?t ; wikibase:timePrecision ?prec . OPTIONAL { ?h wdt:P21 ?sex } OPTIONAL { ?h wikibase:sitelinks ?links } }''' Q_ATTR = { # country for sport 'cfs': PREFIX + 'SELECT ?h ?c WHERE { ?h wdt:P106 wd:%s . ?h wdt:P31 wd:Q5 . ?h wdt:P1532 ?c . }', # country of the place of birth 'bpc': PREFIX + 'SELECT ?h ?c WHERE { ?h wdt:P106 wd:%s . ?h wdt:P31 wd:Q5 . ?h wdt:P19 ?b . ?b wdt:P17 ?c . }', # constituent country of the UK the place of birth lies in 'ukp': PREFIX + '''SELECT ?h ?c WHERE { ?h wdt:P106 wd:%s . ?h wdt:P31 wd:Q5 . ?h wdt:P19 ?b . ?b wdt:P131+ ?c . VALUES ?c { wd:Q21 wd:Q22 wd:Q25 wd:Q26 } }''', # citizenship 'cit': PREFIX + 'SELECT ?h ?c WHERE { ?h wdt:P106 wd:%s . ?h wdt:P31 wd:Q5 . ?h wdt:P27 ?c . }', } Q_ISO = PREFIX + 'SELECT ?c ?iso WHERE { ?c wdt:P297 ?iso . }' def qlever(query: str, dest: Path, tries: int = 6) -> None: if dest.exists() and dest.stat().st_size > 0: return body = urllib.parse.urlencode({'query': query}).encode() for attempt in range(tries): try: req = urllib.request.Request(QLEVER, data=body, headers={'Accept': 'text/tab-separated-values', 'User-Agent': 'startarot-research/1.0'}) with urllib.request.urlopen(req, timeout=600) as r: data = r.read() if not data.startswith(b'?'): raise RuntimeError(f'unexpected answer: {data[:200]!r}') tmp = dest.with_suffix('.part') tmp.write_bytes(data) tmp.rename(dest) log(f' {dest.name}: {data.count(b"\n") - 1} rows') return except Exception as e: # noqa: BLE001 — network: retry log(f' {dest.name}: {e}; retry {attempt + 1}') time.sleep(10 * (attempt + 1)) raise SystemExit(f'QLever failed for {dest.name}') def curl(url: str, dest: Path, tries: int = 6) -> None: if dest.exists() and dest.stat().st_size > 0: return tmp = dest.with_suffix(dest.suffix + '.part') for attempt in range(tries): r = subprocess.run(['curl', '-sSfL', '--retry', '3', '-A', 'startarot-research/1.0', '-o', str(tmp), url]) if r.returncode == 0 and tmp.stat().st_size > 0: tmp.rename(dest) log(f' {dest.name}: {dest.stat().st_size} bytes') return time.sleep(5 * (attempt + 1)) raise SystemExit(f'download failed: {url}') def fetch(): RAW.mkdir(parents=True, exist_ok=True) wd = RAW / 'wikidata' wd.mkdir(exist_ok=True) qlever(Q_ISO, wd / 'iso.tsv') for key, (qid, _) in GROUPS.items(): log(key) qlever(Q_PEOPLE % qid, wd / f'{key}.people.tsv') for attr, q in Q_ATTR.items(): qlever(q % qid, wd / f'{key}.{attr}.tsv') # Official births by month (the first baseline): Eurostat from 1960, HFD STFF from 2000. curl('https://ec.europa.eu/eurostat/api/dissemination/sdmx/2.1/data/demo_fmonth?format=TSV&compressed=true', RAW / 'demo_fmonth.tsv.gz') curl('https://www.humanfertility.org/File/GetDocumentFree/STFF/stff.csv', RAW / 'stff.csv') # Daily births where they exist (summed to months): USA 1969–1988 and 1994–2014, France 1968–. gh = 'https://raw.githubusercontent.com/' curl(gh + 'avehtari/casestudies/master/Birthdays/data/births_usa_1969.csv', RAW / 'births_usa_1969.csv') curl(gh + 'fivethirtyeight/data/master/births/US_births_1994-2003_CDC_NCHS.csv', RAW / 'us_1994_2003.csv') curl(gh + 'fivethirtyeight/data/master/births/US_births_2000-2014_SSA.csv', RAW / 'us_2000_2014.csv') curl('https://www.insee.fr/fr/statistiques/fichier/8582123/T79jnais.csv', RAW / 'fr_T79jnais.csv') # Lahman Baseball Database (SABR, CC BY-SA 3.0): every major-league player, the check of # Wikidata's coverage. Read from the R package's copy (pyreadr) — SABR serves it via Box. curl(gh + 'cdalzell/Lahman/master/data/People.RData', RAW / 'People.RData') # ------------------------------------------------------------------ pre-registered predictions # (group, countries, first birth year, last birth year, cutoff month) — the cutoff is the 1st of the # month (Japan's 2 April counts as April: 1 April babies are dropped with every 1st, see load_people). JAN, APR, AUG, SEP, MAR = 1, 4, 8, 9, 3 CUTOFF_SOURCES = { 'fifa_jan': 'Helsen et al. 2005, J Sports Sci 23:629 (ten European countries); Musch & Hay 1999', 'school_sep': 'FA / ECB / RFU age groups follow the school year from 1 September; Kelly et al. 2021', 'bcci_sep': 'BCCI Age Verification Programme rules 2025-26 (cutoff 1 September)', 'ca_sep': 'Cricket Australia age groups, PeerJ 2019 7:e6867', 'iihf_jan': 'Barnsley et al. 1985 (Canada); IIHF age categories by calendar year', 'rugby_jan': 'U20 World Championship RAE study (UiTM, 2019): calendar-year cohorts', 'au_football': 'van den Honert 2012, J Sports Sci; Musch & Hay 1999: 1 Jan -> 1 Aug (1988) -> 1 Jan (1995)', 'mlb_intl': 'MLB Rule 3(a)(1)(B): an international amateur must be 17 by 1 September of the first season', 'kr_school': 'Korean school year from 1 March until the 2002 birth cohort (Hangyo 2006)', 'us_soccer': 'Smith et al. 2021, Front Sports Act Living 3:635195: 1 Aug until the 2016 reform', } # '*jan': every calendar-year football country of P1 plus the explored ones that switched to # 1 January by the 1990 cohort; '*all': every country with a person in both groups. JAN_FOOTBALL = ['DE', 'NL', 'BE', 'FR', 'IT', 'ES', 'BR', 'RU'] PREDICTIONS = [ # id, kind, group, countries, years, cutoff month, source key, note ('P1', 'directional+sign', 'football', ['PT', 'AR', 'MX', 'PL', 'SE', 'NO', 'DK', 'CZ', 'HR', 'RS', 'AT', 'CH', 'TR', 'GR', 'IE', 'UA', 'RO', 'HU', 'SK', 'SI', 'FI', 'CO', 'UY', 'CL'], (1987, 2007), JAN, 'fifa_jan', 'calendar-year youth football; each country is its own prediction'), ('P2', 'directional+sign', 'cricket', ['GB-ENG', 'IN', 'AU'], (1960, 2007), SEP, 'bcci_sep', 'cricket age groups from 1 September'), ('P3', 'directional+sign', 'rugby_union', ['GB-ENG', 'GB-WLS', 'GB-SCT', 'IE'], (1960, 2007), SEP, 'school_sep', 'rugby in the four home unions follows the school year'), ('P4', 'directional+sign', 'rugby_union', ['NZ', 'ZA', 'AU', 'FR', 'AR', 'IT'], (1975, 2007), JAN, 'rugby_jan', 'calendar-year rugby'), ('P5', 'directional+sign', 'ice_hockey', ['SE', 'FI', 'CZ', 'SK', 'CH', 'DE', 'LV', 'NO', 'AT'], (1960, 2007), JAN, 'iihf_jan', 'calendar-year hockey'), ('P6', 'directional+sign', 'rugby_league', ['GB-ENG'], (1960, 2007), SEP, 'school_sep', 'English rugby league, school year'), ('P7', 'switch', 'football', ['AU'], None, None, 'au_football', 'Australia: cohorts 1981–1987 grew up under 1 August, 1990–2007 under 1 January. The Aug–Oct over ' 'May–Jul ratio is larger in 1981–1987 than in 1990–2007, and the Jan–Mar over Oct–Dec ratio is ' 'larger in 1990–2007 than in 1981–1987'), ('P8', 'directional', 'baseball', ['DO', 'VE'], (1960, 2005), SEP, 'mlb_intl', 'international signings: 1 September eligibility (low confidence)'), ('P9', 'directional', 'football', ['US'], (1975, 2004), AUG, 'us_soccer', 'US youth soccer used 1 August before 2016'), ('P10', 'directional', 'football', ['KR'], (1960, 2001), MAR, 'kr_school', 'Korean school grade from 1 March (diluted by late school entry; low confidence)'), ('P11', 'weaker', 'football_women', ['*jan'], (1990, 2007), JAN, 'fifa_jan', "women's football in calendar-year countries is weaker than men's"), ('P12', 'weaker', 'rowing', ['*jan'], (1990, 2007), JAN, 'fifa_jan', 'rowing (late entry, no small-child age groups) is weaker than football in the same countries'), ('P13', 'weaker', 'darts+snooker+jockey+golf', ['*all'], (1950, 2007), JAN, 'fifa_jan', 'darts, snooker, jockeys and golf together are weaker than football in the same countries'), ('P14', 'flat', 'others', ['*all'], (1950, 2007), None, None, 'politicians, writers, actors, painters, musicians, researchers: 1 Jan and 1 Sep ratios within 0.95–1.05'), ] # ------------------------------------------------------------------ people FIRST_YEAR, LAST_YEAR = 1950, 2007 # birth cohorts analysed (official baselines start in 1960) SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces'] # Conventional newspaper dates (first day of each sign); the Sun's ingress moves by a day between # years, so the day on either side of a boundary is a cusp day and is counted where the table puts it. SIGN_START = [(3, 21), (4, 20), (5, 21), (6, 21), (7, 23), (8, 23), (9, 23), (10, 23), (11, 22), (12, 22), (1, 20), (2, 19)] DIM = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] def sign_of(month: int, day: int) -> int: """Index into SIGNS of a calendar day.""" # the last sign starting on or before the day; before 20 January it is still Capricorn found = 9 for i in sorted(range(12), key=lambda i: SIGN_START[i]): if SIGN_START[i] <= (month, day): found = i return found SIGN_OF = [[sign_of(m + 1, d + 1) if d < DIM[m] else -1 for d in range(31)] for m in range(12)] def qid(cell: str) -> str: return cell.rsplit('/', 1)[-1].rstrip('>') def read_tsv(path: Path) -> list[list[str]]: with path.open(encoding='utf-8') as f: next(f) return [line.rstrip('\n').split('\t') for line in f] def iso_map() -> dict[str, str]: m = {qid(r[0]): r[1].strip('"') for r in read_tsv(RAW / 'wikidata' / 'iso.tsv')} m.update(HISTORICAL) return m def load_people() -> tuple[dict, dict]: """qid -> person; plus a data-quality tally. A person: date (y, m, d), sex, sitelinks, groups, country code and where it came from. Only day-precision dates (Wikidata precision 11) are used: a year-only date is stored as 1 January and a month-only one as the 1st, exactly where a cutoff effect would show. A person with two different day-precision dates is dropped.""" iso = iso_map() people: dict[str, dict] = {} tally = {'rows': 0, 'not_day_precision': 0, 'conflicting_dates': 0, 'out_of_range': 0} for key in GROUPS: base = RAW / 'wikidata' for r in read_tsv(base / f'{key}.people.tsv'): tally['rows'] += 1 h = qid(r[0]) t, prec = r[1].strip('"'), r[2].strip('"') p = people.setdefault(h, {'dates': set(), 'imprecise': False, 'groups': set(), 'sex': '', 'links': 0, 'cfs': set(), 'bpc': set(), 'ukp': set(), 'cit': set()}) p['groups'].add(key) if prec.split('^')[0] != '11': p['imprecise'] = True else: ymd = t.split('T')[0].split('^')[0] neg = ymd.startswith('-') y, m, d = ymd.lstrip('-').split('-') if not neg: p['dates'].add((int(y), int(m), int(d))) if len(r) > 3 and r[3]: p['sex'] = {'Q6581097': 'm', 'Q6581072': 'f'}.get(qid(r[3]), p['sex'] or 'o') if len(r) > 4 and r[4]: p['links'] = max(p['links'], int(r[4].split('^')[0].strip('"'))) for attr in Q_ATTR: for r in read_tsv(base / f'{key}.{attr}.tsv'): h = qid(r[0]) if h in people and len(r) > 1: people[h][attr].add(qid(r[1])) out = {} for h, p in people.items(): if not p['dates']: tally['not_day_precision'] += 1 continue if len(p['dates']) > 1: tally['conflicting_dates'] += 1 continue y, m, d = next(iter(p['dates'])) if not (FIRST_YEAR <= y <= LAST_YEAR) or not (1 <= m <= 12 and 1 <= d <= DIM[m - 1]): tally['out_of_range'] += 1 continue code, how = assign_country(p, iso) out[h] = {'y': y, 'm': m, 'd': d, 'sex': p['sex'], 'links': p['links'], 'groups': p['groups'], 'country': code, 'country_from': how} tally['people'] = len(out) return out, tally def assign_country(p: dict, iso: dict) -> tuple[str | None, str]: """Where the person grew up in sport, as well as Wikidata can tell: 'country for sport' if it names one country; else the country of the place of birth (with the UK split into its four football nations); else a single citizenship.""" def codes(qids): out = set() for q in qids: if q in UK_PARTS: out.add(UK_PARTS[q]) elif q in iso: out.add(iso[q]) return out cfs = codes(p['cfs']) if len(cfs) == 1: c = next(iter(cfs)) if c == 'GB' and len(p['ukp']) == 1: c = UK_PARTS[next(iter(p['ukp']))] return c, 'country for sport' bpc = codes(p['bpc']) if len(p['ukp']) == 1 and (not bpc or bpc <= {'GB', 'IE'}): return UK_PARTS[next(iter(p['ukp']))], 'place of birth' if len(bpc) == 1: return next(iter(bpc)), 'place of birth' cit = codes(p['cit']) if len(cit) == 1: return next(iter(cit)), 'citizenship' return None, 'unknown' # ------------------------------------------------------------------ official baseline # ISO 3166 -> entity of the births tables (Eurostat's codes; UK nations use the UK series) def entity_of(code: str | None) -> str | None: if code is None: return None if code.startswith('GB'): return 'UK' return {'GR': 'EL'}.get(code, code) # Series counted by month of REGISTRATION (offices closed at weekends) or with untraceable gaps are # not a baseline of births: the screen of the 710-million-births study (rarest-zodiac-sign). EUROSTAT_NOT_USED = {'UA', 'AZ', 'MD', 'XK'} STFF_ENTITY = {'AUT': 'AT', 'BEL': 'BE', 'BGR': 'BG', 'CHE': 'CH', 'CZE': 'CZ', 'DNK': 'DK', 'ESP': 'ES', 'EST': 'EE', 'FIN': 'FI', 'GRC': 'EL', 'HRV': 'HR', 'HUN': 'HU', 'IRL': 'IE', 'ISL': 'IS', 'ITA': 'IT', 'LTU': 'LT', 'LUX': 'LU', 'LVA': 'LV', 'NLD': 'NL', 'NOR': 'NO', 'PRT': 'PT', 'ROU': 'RO', 'SRB': 'RS', 'SVN': 'SI', 'SWE': 'SE', 'DEUTNP': 'DE', 'AUS': 'AU', 'CAN': 'CA', 'CHL': 'CL', 'ISR': 'IL', 'JPN': 'JP', 'KOR': 'KR', 'NZL': 'NZ', 'SGP': 'SG', 'USA': 'US', 'RUS': 'RU', 'GBR_NP': 'UK', 'SVK': 'SK', 'POL': 'PL'} # last year STFF metadata (23.02.2026) calls final and by date of occurrence STFF_FINAL = {'AUT': 2024, 'BEL': 2024, 'BGR': 2024, 'CHE': 2024, 'CZE': 2024, 'DNK': 2024, 'ESP': 2023, 'EST': 2024, 'FIN': 2024, 'GRC': 2023, 'HRV': 2024, 'HUN': 2024, 'IRL': 2023, 'ISL': 2024, 'ITA': 2023, 'LTU': 2023, 'LUX': 2024, 'LVA': 2024, 'NLD': 2023, 'NOR': 2024, 'PRT': 2024, 'ROU': 2023, 'SRB': 2024, 'SVN': 2024, 'SWE': 2024, 'DEUTNP': 2024, 'AUS': 2023, 'CAN': 2023, 'CHL': 2022, 'ISR': 2023, 'JPN': 2024, 'KOR': 2023, 'NZL': 2024, 'SGP': 2024, 'USA': 2024, 'RUS': 2020, 'GBR_NP': 2022, 'SVK': 2015, 'POL': 2022} STFF_FIRST = {'GBR_NP': 2006} MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] MAX_UNKNOWN_MONTH = 0.02 # A country-year whose smallest month, per day, is under this share of its yearly mean per day is a # broken series (a month reported late, e.g. Eurostat Poland 1995 with December at 0.65) and is dropped: # real birth seasons stay within about ±15%. Added after the critic's review of 28.10.2026. MIN_MONTH_SHARE = 0.8 # A cohort year without its own baseline borrows the nearest year of the same country within this. MAX_BASELINE_GAP = 10 def official_baseline() -> tuple[dict, dict]: """(entity, year) -> 12 monthly birth counts, and (entity, year) -> source. Priority: daily series > HFD STFF > Eurostat.""" import numpy as np import pandas as pd base, src = {}, {} t = pd.read_csv(RAW / 'demo_fmonth.tsv.gz', sep='\t', dtype=str) key = t.columns[0] parts = t[key].str.split(',', expand=True) parts.columns = ['freq', 'unit', 'month', 'geo'] t = pd.concat([parts, t.drop(columns=key)], axis=1).melt( id_vars=['freq', 'unit', 'month', 'geo'], var_name='year', value_name='v') t['year'] = t.year.str.strip().astype(int) t['flag'] = t.v.str.extract(r'([a-z]+)\s*$')[0].fillna('') t['v'] = pd.to_numeric(t.v.str.replace(r'[^0-9.]', '', regex=True), errors='coerce') t = t[t.v.notna()] # Germany with the GDR, metropolitan France: 'DE' is without the GDR, 'FR' with overseas from 1994 t = t[~t.geo.isin(['DE', 'FR'])] t.loc[t.geo == 'DE_TOT', 'geo'] = 'DE' t.loc[t.geo == 'FX', 'geo'] = 'FR' t = t[(t.geo.str.len() == 2) & ~t.geo.isin(EUROSTAT_NOT_USED)] last = t.groupby('geo').year.transform('max') t = t[~(t.flag.str.contains('p') & (t.year >= last - 1))] for (geo, year), g in t.groupby(['geo', 'year']): m = {r.month: r.v for r in g.itertuples()} months = [m.get(f'M{i:02d}') for i in range(1, 13)] if any(v is None for v in months): continue known = sum(months) if m.get('UNK', 0) > MAX_UNKNOWN_MONTH * (known + m.get('UNK', 0)) or known <= 0: continue base[(geo, year)] = np.array(months, dtype=float) src[(geo, year)] = 'Eurostat' s = pd.read_csv(RAW / 'stff.csv', na_values='.') for code, ent in STFF_ENTITY.items(): for r in s[(s.CountryCode == code) & s.Year.between(STFF_FIRST.get(code, 2000), STFF_FINAL[code])].itertuples(): months = [getattr(r, m) for m in MONTHS_EN] if any(pd.isna(v) for v in months): continue base[(ent, int(r.Year))] = np.array(months, dtype=float) src[(ent, int(r.Year))] = 'HFD STFF' for ent, series in daily_series().items(): frame = series.groupby([series.index.year, series.index.month]).sum() for year in sorted({y for y, _ in frame.index}): months = [frame.get((year, m)) for m in range(1, 13)] if any(v is None or v != v for v in months): continue base[(ent, int(year))] = np.array(months, dtype=float) src[(ent, int(year))] = 'daily' for k in list(base): per_day = base[k] / np.array(DIM, dtype=float) if per_day.min() < MIN_MONTH_SHARE * per_day.mean(): del base[k], src[k] return base, src def daily_series() -> dict: import pandas as pd out = {} a = pd.read_csv(RAW / 'births_usa_1969.csv') a = a[a.year <= 1988] a = pd.Series(a.births.values, index=pd.to_datetime(dict(year=a.year, month=a.month, day=a.day))) b = pd.read_csv(RAW / 'us_1994_2003.csv') b = b[b.year < 2000] c = pd.read_csv(RAW / 'us_2000_2014.csv') bc = pd.concat([b, c]) bc = pd.Series(bc.births.values, index=pd.to_datetime(dict(year=bc.year, month=bc.month, day=bc.date_of_month))) out['US'] = pd.concat([a, bc]).sort_index() f = pd.read_csv(RAW / 'fr_T79jnais.csv', sep=';', dtype=str) f = f[(f.CHAMP2 == 'FM') & (f.JOUR != 'MM') & (f.NBNAIS != 'so')] year_col = [c for c in f.columns if c.startswith('ANNEE')][0] idx = pd.to_datetime(f[year_col] + '-' + f.MOIS + '-' + f.JOUR, errors='coerce') keep = idx.notna().values out['FR'] = pd.Series(f.NBNAIS.astype(int).values[keep], index=idx[keep]).sort_index() return out # ------------------------------------------------------------------ cells def leap(y: int) -> bool: return y % 4 == 0 and (y % 100 != 0 or y % 400 == 0) def day_mask(): """12×31: valid calendar days other than the 1st of a month (29 February counts).""" import numpy as np m = np.zeros((12, 31), dtype=bool) for mo in range(12): m[mo, 1:DIM[mo]] = True return m # Countries mostly south of the equator: their fallback is the southern official season. SOUTH = {'AR', 'AU', 'NZ', 'ZA', 'BR', 'UY', 'CL', 'PE', 'BO', 'PY', 'ZW', 'ZM', 'MZ', 'MG', 'NA', 'BW', 'LS', 'SZ', 'AO', 'MW', 'FJ', 'PG', 'TZ', 'RW', 'BI', 'MU', 'SB', 'VU', 'WS', 'TO'} SOUTH_SERIES = ('AU', 'NZ', 'CL') MIN_OTHERS = 2000 # famous non-athletes of a country within ±10 birth years for their own season class Baselines: """Expected births per calendar day for a country and birth year. ``mode``: 'main' (official births; else the nearest official year; else famous non-athletes of the same country; else the official season of the hemisphere), 'flat' (every day alike) or 'others' (famous non-athletes only, wherever there are enough) — the last two are the robustness runs.""" def __init__(self, people: dict, mode: str = 'main'): import numpy as np self.np = np self.mode = mode self.official, self.source = official_baseline() self.years_of = {} for (ent, y) in self.official: self.years_of.setdefault(ent, []).append(y) # second baseline: famous people outside sport, per entity and birth year self.others = {} for p in people.values(): if p['groups'] & set(SPORTS) or not p['groups'] & set(OTHERS): continue ent = entity_of(p['country']) if ent is None: continue arr = self.others.setdefault(ent, np.zeros((LAST_YEAR - FIRST_YEAR + 1, 12, 31))) arr[p['y'] - FIRST_YEAR, p['m'] - 1, p['d'] - 1] += 1 # hemisphere seasons: the births-per-day profile of every official series with EQUAL weight # (summing counts would make the north the USA and Japan); the north year by year self.north, south = {}, np.zeros(12) for (ent, y), months in self.official.items(): per_day = months / np.array([DIM[m] if m != 1 else (29 if leap(y) else 28) for m in range(12)]) share = per_day / per_day.sum() if ent in SOUTH_SERIES: south += share elif ent not in SOUTH: self.north[y] = self.north.get(y, 0) + share self.south = south / south.sum() self.cache = {} def _fill(self, per_day_by_month, year): np = self.np mat = np.zeros((12, 31)) for mo in range(12): n = DIM[mo] if mo != 1 else (29 if leap(year) else 28) mat[mo, :n] = per_day_by_month[mo] return mat def _monthly(self, months, year): days = [DIM[mo] if mo != 1 else (29 if leap(year) else 28) for mo in range(12)] return [months[mo] / days[mo] for mo in range(12)] def _others(self, ent, year): o = self.others.get(ent) if o is None: return None lo, hi = max(year - 10, FIRST_YEAR), min(year + 10, LAST_YEAR) block = o[lo - FIRST_YEAR:hi - FIRST_YEAR + 1].sum(axis=0) if block[:, 1:].sum() < MIN_OTHERS: return None # the 1sts of the others are as unreliable as the athletes': per month the mean of days 2.. return [block[mo, 1:DIM[mo] if mo != 1 else 28].mean() for mo in range(12)] def year_matrix(self, ent: str, year: int): """12×31 share of a year's births per calendar day (uniform within a month), and how it was found: 'official', 'nearest year', 'others', 'hemisphere', 'flat'.""" np = self.np key = (ent, year) if key in self.cache: return self.cache[key] per_day, how = None, None if self.mode == 'flat': per_day, how = [1.0] * 12, 'flat' elif self.mode == 'others': per_day = self._others(ent, year) how = 'others' if per_day is not None else None else: if (ent, year) in self.official: per_day, how = self._monthly(self.official[(ent, year)], year), 'official' elif ent in self.years_of: near = min(self.years_of[ent], key=lambda y: (abs(y - year), y)) if abs(near - year) <= MAX_BASELINE_GAP: per_day, how = self._monthly(self.official[(ent, near)], near), 'nearest year' if per_day is None: per_day = self._others(ent, year) how = 'others' if per_day is not None else None if per_day is None: if ent in SOUTH: per_day, how = list(self.south), 'hemisphere' else: y = min(self.north, key=lambda k: (abs(k - year), k)) per_day, how = list(self.north[y] / self.north[y].sum()), 'hemisphere' if per_day is None: per_day, how = [1.0] * 12, 'flat' mat = self._fill(per_day, year) mat = mat / mat.sum() self.cache[key] = (mat, how) return mat, how def cell(people_list, bl: Baselines) -> dict: """Observed and expected counts (12×31, 1sts masked) for a list of people, and the share of expected weight by baseline kind.""" import numpy as np obs = np.zeros((12, 31)) exp = np.zeros((12, 31)) kinds = {} by_year = {} for p in people_list: obs[p['m'] - 1, p['d'] - 1] += 1 ent = entity_of(p['country']) by_year[(ent, p['y'])] = by_year.get((ent, p['y']), 0) + 1 for (ent, y), n in by_year.items(): mat, how = bl.year_matrix(ent, y) exp += n * mat kinds[how] = kinds.get(how, 0) + n mask = day_mask() o = np.where(mask, obs, 0) e = np.where(mask, exp, 0) if e.sum() > 0: e = e * o.sum() / e.sum() total = sum(kinds.values()) or 1 return {'obs': o, 'exp': e, 'n': int(len(people_list)), 'n_used': int(o.sum()), 'baseline': {k: round(v / total, 4) for k, v in sorted(kinds.items())}} def month_ratio(c): o, e = c['obs'].sum(axis=1), c['exp'].sum(axis=1) return o, e def sign_counts(c): import numpy as np o, e = np.zeros(12), np.zeros(12) for mo in range(12): for d in range(DIM[mo]): s = SIGN_OF[mo][d] o[s] += c['obs'][mo, d] e[s] += c['exp'][mo, d] return o, e def quarter_ratio(c, cutoff: int) -> dict: """Oldest quarter (the three months from the cutoff) against the youngest (the three before), each observed over expected; z of the log ratio with Poisson errors.""" o, e = month_ratio(c) old = [(cutoff - 1 + k) % 12 for k in range(3)] young = [(cutoff - 1 + k) % 12 for k in range(9, 12)] oo, eo, oy, ey = o[old].sum(), e[old].sum(), o[young].sum(), e[young].sum() if min(oo, oy) == 0 or min(eo, ey) == 0: return {'ratio': None, 'z': None, 'lo': None, 'hi': None} lr = math.log((oo / eo) / (oy / ey)) se = math.sqrt(1 / oo + 1 / oy) return {'ratio': math.exp(lr), 'z': lr / se, 'lo': math.exp(lr - 1.96 * se), 'hi': math.exp(lr + 1.96 * se)} def top_signs_after(cutoff: int) -> list[int]: """The three signs whose days lie mostly in the three months from the cutoff.""" months = {(cutoff - 1 + k) % 12 for k in range(3)} share = [] for s in range(12): days = [(mo, d) for mo in range(12) for d in range(DIM[mo]) if SIGN_OF[mo][d] == s and not (mo == 1 and d == 28)] share.append((sum(1 for mo, _ in days if mo in months) / len(days), s)) return [s for sh, s in sorted(share, reverse=True)[:3]] # ------------------------------------------------------------------ compute MIN_CELL = 300 # people in a sport × country cell before it gets its own wheel EXPLORED = {('football', c) for c in ['DE', 'NL', 'GB-ENG', 'GB-SCT', 'GB-WLS', 'GB-NIR', 'JP', 'FR', 'BR', 'BE', 'IT', 'ES', 'RU']} | {('baseball', 'US'), ('baseball', 'JP'), ('ice_hockey', 'CA'), ('ice_hockey', 'RU')} # Documented cutoffs (sport, country, first year, last year, cutoff month, source key) — the cells of # the "collapse" figure. The explored cells are here too; the predictions are scored separately. DOCUMENTED = ( [('football', c, 1990, 2007, JAN, 'fifa_jan') for c in JAN_FOOTBALL + PREDICTIONS[0][3]] + [('football', c, 1960, 1979, AUG, 'de_be_switch') for c in ['DE', 'BE']] + [('football', 'GB-ENG', 1960, 2007, SEP, 'school_sep'), ('football', 'JP', 1960, 2007, APR, 'jp_school'), ('baseball', 'JP', 1960, 2007, APR, 'jp_school'), ('baseball', 'US', 1950, 1993, AUG, 'little_league'), ('baseball', 'US', 1994, 2007, 5, 'little_league'), ('football', 'AU', 1981, 1987, AUG, 'au_football'), ('football', 'AU', 1990, 2007, JAN, 'au_football'), ('football', 'US', 1975, 2004, AUG, 'us_soccer')] + [('ice_hockey', c, 1960, 2007, JAN, 'iihf_jan') for c in ['CA', 'RU'] + PREDICTIONS[4][3]] + [('cricket', c, 1960, 2007, SEP, 'bcci_sep') for c in PREDICTIONS[1][3]] + [('rugby_union', c, 1960, 2007, SEP, 'school_sep') for c in PREDICTIONS[2][3]] + [('rugby_union', c, 1975, 2007, JAN, 'rugby_jan') for c in PREDICTIONS[3][3]] + [('rugby_league', 'GB-ENG', 1960, 2007, SEP, 'school_sep')] ) CUTOFF_SOURCES.update({ 'de_be_switch': 'Germany and Belgium: 1 August until 1997 (Helsen et al. 2000; Ostapczuk & Musch 2013)', 'jp_school': 'Japanese school year from 2 April (J-League RAE studies, IJSSC 2022)', 'little_league': 'Little League: 31 July until 2006, then 30 April (littleleague.org; Slate 2008)', }) EXPERIMENT_BINS = 3 # birth years per point of the natural-experiment series FAKE_DRAWS = 10000 # random wrong cutoffs per documented cell MIN_ERA_CELL = 200 # people in a sport × country × era cell of the lookup LOOKUP_ERAS = {'all': (FIRST_YEAR, LAST_YEAR), 'before1985': (FIRST_YEAR, 1984), 'from1985': (1985, LAST_YEAR)} # Cells printed in full on the page: name, sport, country, birth years ERAS = [('de_early', 'football', 'DE', 1956, 1979), ('de_late', 'football', 'DE', 1990, 2007), ('nl_early', 'football', 'NL', 1956, 1979), ('nl_late', 'football', 'NL', 1990, 2007), ('be_early', 'football', 'BE', 1956, 1979), ('be_late', 'football', 'BE', 1990, 2007), ('eng', 'football', 'GB-ENG', 1956, 2007), ('jp', 'football', 'JP', 1956, 2007), ('jp_baseball', 'baseball', 'JP', 1956, 2007), ('us_baseball_early', 'baseball', 'US', 1950, 1993), ('us_baseball_late', 'baseball', 'US', 1994, 2007), ('ca_hockey', 'ice_hockey', 'CA', 1950, 2007), ('in_cricket', 'cricket', 'IN', 1950, 2007), ('eng_cricket', 'cricket', 'GB-ENG', 1950, 2007), ('jan_football', 'football', None, 1990, 2007)] _MEMBERS: dict = {} def members(people: dict, group: str) -> list: """Men of a sport ('football_women': women footballers); 'others': famous non-athletes.""" key = (id(people), group) if key not in _MEMBERS: _MEMBERS[key] = _members(people, group) return _MEMBERS[key] def _members(people: dict, group: str) -> list: if group == 'football_women': return [p for p in people.values() if 'football' in p['groups'] and p['sex'] == 'f'] if group == 'others': return [p for p in people.values() if p['groups'] & set(OTHERS) and not p['groups'] & set(SPORTS)] if group in OTHERS: return [p for p in people.values() if group in p['groups'] and not p['groups'] & set(SPORTS)] keys = group.split('+') return [p for p in people.values() if p['groups'] & set(keys) and p['sex'] == 'm'] def pick(lst, countries=None, years=None, links=None): out = [] for p in lst: if countries is not None and p['country'] not in countries: continue if years is not None and not (years[0] <= p['y'] <= years[1]): continue if links is not None and not (links[0] <= p['links'] < links[1]): continue out.append(p) return out def summary(c, cutoffs=(JAN, APR, AUG, SEP)) -> dict: mo, me = month_ratio(c) so, se = sign_counts(c) sr = [float(o / e) if e > 0 else None for o, e in zip(so, se)] valid = [i for i in range(12) if sr[i] is not None] return {'n': c['n'], 'n_used': c['n_used'], 'baseline': c['baseline'], 'month_obs': [int(x) for x in mo], 'month_exp': [round(float(x), 2) for x in me], 'sign_obs': [int(x) for x in so], 'sign_exp': [round(float(x), 2) for x in se], 'sign_ratio': [round(x, 4) if x is not None else None for x in sr], 'top_sign': max(valid, key=lambda i: sr[i]) if valid else None, 'bottom_sign': min(valid, key=lambda i: sr[i]) if valid else None, 'quarters': {str(k): {kk: (round(v, 4) if v is not None else None) for kk, v in quarter_ratio(c, k).items()} for k in cutoffs}} def lahman_people() -> list: import pyreadr t = list(pyreadr.read_r(str(RAW / 'People.RData')).values())[0] t = t[(t.birthCountry == 'USA') & t.birthYear.between(FIRST_YEAR, LAST_YEAR) & t.birthMonth.notna() & t.birthDay.notna()] return [{'y': int(r.birthYear), 'm': int(r.birthMonth), 'd': int(r.birthDay), 'country': 'US', 'links': 0, 'sex': 'm', 'groups': {'baseball'}} for r in t.itertuples()] def score_predictions(people, bl) -> list: out = [] men_football = members(people, 'football') for pid, kind, group, countries, years, cutoff, src, note in PREDICTIONS: if kind in ('directional+sign', 'directional'): lst = members(people, group) for ctry in countries: c = cell(pick(lst, {ctry}, years), bl) q = quarter_ratio(c, cutoff) so, se = sign_counts(c) top = int(max(range(12), key=lambda i: so[i] / se[i] if se[i] else -1)) if c['n_used'] else None hit_dir = q['z'] is not None and q['z'] > 1.645 row = {'id': pid, 'kind': kind, 'group': group, 'country': ctry, 'years': list(years), 'cutoff': cutoff, 'n': c['n_used'], 'ratio': q['ratio'], 'lo': q['lo'], 'hi': q['hi'], 'z': q['z'], 'hit_direction': bool(hit_dir), 'top_sign': top, 'note': note, 'source': src} if kind == 'directional+sign': row['predicted_signs'] = top_signs_after(cutoff) row['hit_sign'] = top in row['predicted_signs'] if top is not None else False out.append(row) elif kind == 'switch': lst = members(people, group) a = cell(pick(lst, set(countries), (1981, 1987)), bl) b = cell(pick(lst, set(countries), (1990, 2007)), bl) qa_aug, qb_aug = quarter_ratio(a, AUG), quarter_ratio(b, AUG) qa_jan, qb_jan = quarter_ratio(a, JAN), quarter_ratio(b, JAN) hit = all(x['ratio'] is not None for x in (qa_aug, qb_aug, qa_jan, qb_jan)) and \ qa_aug['ratio'] > qb_aug['ratio'] and qb_jan['ratio'] > qa_jan['ratio'] out.append({'id': pid, 'kind': kind, 'group': group, 'country': countries[0], 'note': note, 'source': src, 'n_early': a['n_used'], 'n_late': b['n_used'], 'aug_early': qa_aug, 'aug_late': qb_aug, 'jan_early': qa_jan, 'jan_late': qb_jan, 'hit': bool(hit)}) elif kind == 'weaker': ctry = set(JAN_FOOTBALL + PREDICTIONS[0][3]) if countries == ['*jan'] else None lst = pick(members(people, group), ctry, years) if ctry is None: ctry = {p['country'] for p in lst} ref = cell(pick(men_football, ctry, years), bl) c = cell(lst, bl) q, qr = quarter_ratio(c, cutoff), quarter_ratio(ref, cutoff) hit = q['ratio'] is not None and qr['ratio'] is not None and abs(math.log(q['ratio'])) < abs(math.log(qr['ratio'])) out.append({'id': pid, 'kind': kind, 'group': group, 'note': note, 'source': src, 'n': c['n_used'], 'ratio': q['ratio'], 'lo': q['lo'], 'hi': q['hi'], 'ref_n': ref['n_used'], 'ref_ratio': qr['ratio'], 'hit': bool(hit)}) elif kind == 'flat': rows = [] for g in OTHERS: c = cell(pick(members(people, g), None, years), bl) rows.append({'group': g, 'n': c['n_used'], 'jan': quarter_ratio(c, JAN), 'sep': quarter_ratio(c, SEP)}) hit = all(0.95 <= r[k]['ratio'] <= 1.05 for r in rows for k in ('jan', 'sep')) out.append({'id': pid, 'kind': kind, 'group': group, 'note': note, 'rows': rows, 'hit': bool(hit)}) return out def compute(): import numpy as np people, tally = load_people() log('people', tally) # Wikidata's 1sts: day 1 of each month against the mean of days 2–28, before any masking first = [] for mo in range(12): days = np.zeros(31) for p in people.values(): if p['m'] == mo + 1: days[p['d'] - 1] += 1 first.append(round(float(days[0] / days[1:28].mean()), 3)) tally['first_of_month_ratio'] = first bl = Baselines(people) res = {'tally': tally, 'groups': {k: v[1] for k, v in GROUPS.items()}, 'signs': SIGNS, 'sign_start': SIGN_START, 'first_year': FIRST_YEAR, 'last_year': LAST_YEAR} # country source tally src = {} for p in people.values(): src[p['country_from']] = src.get(p['country_from'], 0) + 1 res['country_from'] = src # sports pooled and sport × country cells res['sports'], res['cells'] = {}, {} for key in list(SPORTS) + ['football_women']: lst = members(people, key) c = cell([p for p in lst if p['country']], bl) res['sports'][key] = summary(c) by = {} for p in lst: if p['country']: by.setdefault(p['country'], []).append(p) for ctry, ps in by.items(): if len(ps) >= MIN_CELL: res['cells'].setdefault(key, {})[ctry] = summary(cell(ps, bl)) log(key, len(lst), 'cells', len(res['cells'].get(key, {}))) for key in OTHERS: res['sports'][key] = summary(cell([p for p in members(people, key) if p['country']], bl)) # natural experiments: quarter ratios by birth-year bin exps = {} lah = lahman_people() series_def = [('football', 'DE'), ('football', 'NL'), ('football', 'BE'), ('football', 'AU'), ('baseball', 'US'), ('lahman', 'US'), ('football', 'GB-ENG'), ('football', 'JP')] for group, ctry in series_def: lst = lah if group == 'lahman' else pick(members(people, group), {ctry}) pts = [] for y0 in range(1956, LAST_YEAR + 1, EXPERIMENT_BINS): y1 = min(y0 + EXPERIMENT_BINS - 1, LAST_YEAR) c = cell(pick(lst, None, (y0, y1)), bl) if c['n_used'] < 60: continue pts.append({'years': [y0, y1], 'n': c['n_used'], **{f'q{k}': quarter_ratio(c, k) for k in (JAN, APR, 5, AUG, SEP)}, 'month_obs': [int(x) for x in month_ratio(c)[0]], 'month_exp': [round(float(x), 2) for x in month_ratio(c)[1]]}) exps[f'{group}:{ctry}'] = pts res['experiments'] = exps # Lahman against Wikidata, same cohorts wd_us = pick(members(people, 'baseball'), {'US'}) lv = {} for name, yrs in [('1950-1993', (1950, 1993)), ('1994-2005', (1994, 2005))]: a, b = cell(pick(lah, None, yrs), bl), cell(pick(wd_us, None, yrs), bl) ra = month_ratio(a)[0] / month_ratio(a)[1] rb = month_ratio(b)[0] / month_ratio(b)[1] lv[name] = {'lahman': summary(a, (AUG, 5)), 'wikidata': summary(b, (AUG, 5)), 'month_corr': round(float(np.corrcoef(ra, rb)[0, 1]), 4)} res['lahman'] = lv # collapse: month ratio by months since the cutoff, every documented cell col = [] pooled_o, pooled_e = np.zeros(12), np.zeros(12) for sport, ctry, y0, y1, cut, srckey in DOCUMENTED: c = cell(pick(members(people, sport), {ctry}, (y0, y1)), bl) if c['n_used'] < 200: continue o, e = month_ratio(c) idx = [(cut - 1 + k) % 12 for k in range(12)] ro, re_ = o[idx], e[idx] pooled_o += ro pooled_e += re_ col.append({'sport': sport, 'country': ctry, 'years': [y0, y1], 'cutoff': cut, 'source': srckey, 'explored': (sport, ctry) in EXPLORED, 'n': c['n_used'], 'by_month_since': [round(float(a / b), 4) if b else None for a, b in zip(ro, re_)], 'by_calendar_month': [round(float(a / b), 4) if b else None for a, b in zip(o, e)]}) res['collapse'] = {'cells': col, 'pooled': [round(float(a / b), 4) for a, b in zip(pooled_o, pooled_e)], 'pooled_obs': [int(x) for x in pooled_o], 'pooled_exp': [round(float(x), 1) for x in pooled_e]} # fake cutoffs: every documented cell gets a wrong cutoff month at random; how often does the # pooled oldest-over-youngest quarter ratio reach the real one? rng = np.random.default_rng(20261028) obs_cal = [] for sport, ctry, y0, y1, cut, srckey in DOCUMENTED: c = cell(pick(members(people, sport), {ctry}, (y0, y1)), bl) if c['n_used'] < 200: continue o, e = month_ratio(c) obs_cal.append((o, e, cut, (sport, ctry) in EXPLORED)) def pooled_q(cells, cuts): oo = oy = eo = ey = 0.0 for (o, e, *_), k in zip(cells, cuts): old = [(k - 1 + j) % 12 for j in range(3)] young = [(k - 1 + j) % 12 for j in range(9, 12)] oo += o[old].sum(); eo += e[old].sum(); oy += o[young].sum(); ey += e[young].sum() return (oo / eo) / (oy / ey) def fake_test(cells): real = pooled_q(cells, [c for _, _, c, _ in cells]) fakes = [] for _ in range(FAKE_DRAWS): cuts = [(true - 1 + int(rng.integers(1, 12))) % 12 + 1 for _, _, true, _ in cells] fakes.append(pooled_q(cells, cuts)) fakes = np.array(fakes) return {'real': real, 'draws': FAKE_DRAWS, 'max_fake': float(fakes.max()), 'p95_fake': float(np.quantile(fakes, 0.95)), 'median_fake': float(np.median(fakes)), 'share_at_least_real': float((fakes >= real).mean()), 'cells': len(cells), 'min_players': 200} # All documented cells, and — because most of them use 1 January, and a Jan–Mar excess common # to all athletes for any other reason would also beat random dates — the other cutoffs alone. res['fake_cutoffs'] = fake_test(obs_cal) res['fake_cutoffs']['jan_cells'] = sum(1 for _, _, c, _ in obs_cal if c == JAN) res['fake_cutoffs_other'] = fake_test([x for x in obs_cal if x[2] != JAN]) # The same, without the cells of the first look (their cutoffs were chosen with the data seen): # only groups whose cutoff was predicted blind. Added after the critic's review of 28.10.2026. blind = [x for x in obs_cal if x[2] != JAN and not x[3]] res['fake_cutoffs_blind'] = fake_test(blind) # eras of the headline countries: full sign and month tables res['eras'] = {} for name, sport, ctry, y0, y1 in ERAS: res['eras'][name] = {'sport': sport, 'country': ctry, 'years': [y0, y1], **summary(cell(pick(members(people, sport), set(JAN_FOOTBALL + PREDICTIONS[0][3]) if ctry is None else {ctry}, (y0, y1)), bl), (JAN, APR, 5, AUG, SEP))} lah_early = cell(pick(lah, None, (1950, 1993)), bl) lah_late = cell(pick(lah, None, (1994, 2005)), bl) res['eras']['lahman_early'] = {'sport': 'baseball', 'country': 'US', 'years': [1950, 1993], **summary(lah_early, (JAN, APR, 5, AUG, SEP))} res['eras']['lahman_late'] = {'sport': 'baseball', 'country': 'US', 'years': [1994, 2005], **summary(lah_late, (JAN, APR, 5, AUG, SEP))} # the lookup: sport × country × era look = {} for key in list(SPORTS) + ['football_women']: lst = [p for p in members(people, key) if p['country']] by = {} for p in lst: by.setdefault(p['country'], []).append(p) for ctry, ps in by.items(): for era, (y0, y1) in LOOKUP_ERAS.items(): sel = pick(ps, None, (y0, y1)) if len(sel) < (MIN_CELL if era == 'all' else MIN_ERA_CELL): continue c = cell(sel, bl) so, se = sign_counts(c) mo, me = month_ratio(c) look.setdefault(key, {}).setdefault(ctry, {})[era] = { 'n': c['n_used'], 'sign_obs': [int(x) for x in so], 'sign_exp': [round(float(x), 2) for x in se], 'month_obs': [int(x) for x in mo], 'month_exp': [round(float(x), 2) for x in me], 'baseline': c['baseline']} res['lookup'] = look # fame: football, calendar-year countries, 1990–2007, by Wikipedia editions (sitelinks) fb = pick(members(people, 'football'), set(JAN_FOOTBALL + PREDICTIONS[0][3]), (1990, 2007)) links = sorted(p['links'] for p in fb) edges = [links[int(len(links) * q)] for q in (0, 0.5, 0.8, 0.95, 0.99)] + [10 ** 9] fame = [] for lo, hi in zip(edges[:-1], edges[1:]): if lo == hi: continue c = cell(pick(fb, None, None, (lo, hi)), bl) fame.append({'links': [lo, hi], 'n': c['n_used'], 'q': quarter_ratio(c, JAN)}) res['fame'] = fame res['predictions'] = score_predictions(people, bl) # the non-athletes against official births only (the main baseline borrows from them elsewhere) po = [] for g in OTHERS: lst = [p for p in members(people, g) if p['country'] and bl.year_matrix(entity_of(p['country']), p['y'])[1] in ('official', 'nearest year')] c = cell(lst, bl) po.append({'group': g, 'n': c['n_used'], **{str(k): quarter_ratio(c, k) for k in (JAN, APR, AUG, SEP)}}) res['placebo_official'] = po # where the expected counts of the headline cells come from # the same predictions against the two other baselines: every day alike, famous non-athletes res['robustness'] = {} for mode in ('flat', 'others'): res['robustness'][mode] = score_predictions(people, Baselines(people, mode)) res['cutoff_sources'] = CUTOFF_SOURCES # overall baseline mix allc = cell([p for p in people.values() if p['country'] and p['groups'] & set(SPORTS)], bl) res['baseline_mix'] = allc['baseline'] res['n_athletes'] = sum(1 for p in people.values() if p['groups'] & set(SPORTS)) res['n_athletes_country'] = allc['n'] res['n_others'] = sum(1 for p in people.values() if p['groups'] & set(OTHERS) and not p['groups'] & set(SPORTS)) def clean(x): if isinstance(x, dict): return {str(k): clean(v) for k, v in x.items()} if isinstance(x, (list, tuple)): return [clean(v) for v in x] if isinstance(x, (np.floating, float)): return None if x != x else round(float(x), 5) if isinstance(x, (np.integer,)): return int(x) if isinstance(x, set): return sorted(x) return x (DATA / 'computed.json').write_text(json.dumps(clean(res), ensure_ascii=False)) log('computed.json written') # ------------------------------------------------------------------ validate def validate() -> bool: """Checks that do not depend on what the page wants to say. Writes validation.json; export refuses to run while it lists problems.""" r = json.loads((DATA / 'computed.json').read_text()) problems, notes = [], [] # the sign table: every day of a leap year has exactly one sign, 29–32 days each days = [sum(1 for mo in range(12) for d in range(DIM[mo]) if SIGN_OF[mo][d] == s) for s in range(12)] if sum(days) != 366 or not all(29 <= d <= 32 for d in days): problems.append(f'sign table: days per sign {days}') if [SIGN_OF[m - 1][d - 1] for m, d in [(3, 20), (3, 21), (1, 19), (1, 20), (12, 21), (12, 22)]] != [11, 0, 9, 10, 8, 9]: problems.append('sign boundaries moved') t = r['tally'] if t['people'] < 1_000_000: problems.append(f"only {t['people']} people with a day-precision date in {FIRST_YEAR}–{LAST_YEAR}") # the 1st of January is where imprecise dates gather; it must stand out (else the masking # rationale is gone) and the other 1sts must not stand out much more than ordinary days f = t['first_of_month_ratio'] if f[0] < 1.5: notes.append(f'1 January is only {f[0]}× an ordinary January day') if max(f[1:]) > 1.5: problems.append(f'a 1st other than 1 January stands out: {f}') # observed and expected totals agree in every cell (expected is scaled to the used days) for key, v in r['sports'].items(): if abs(sum(v['sign_obs']) - sum(v['sign_exp'])) > 0.01 * max(1, sum(v['sign_obs'])): problems.append(f'{key}: observed and expected totals differ') # Lahman (every major leaguer) against Wikidata: same month pattern before 1994 corr = r['lahman']['1950-1993']['month_corr'] if corr < 0.8: problems.append(f'Lahman and Wikidata disagree on US baseball months (r = {corr})') # Wikidata covers most major leaguers born in the USA nl, nw = r['lahman']['1950-1993']['lahman']['n_used'], r['lahman']['1950-1993']['wikidata']['n_used'] if nw < 0.8 * nl: problems.append(f'Wikidata has {nw} US baseball players vs {nl} major leaguers in Lahman') # baselines: the robustness runs exist and agree on the prediction tally within two cells def hits(preds): return sum(p.get('hit_direction', False) for p in preds if p['kind'].startswith('directional')) main = hits(r['predictions']) for mode, preds in r['robustness'].items(): if abs(hits(preds) - main) > 2: notes.append(f'baseline {mode}: {hits(preds)} directional hits vs {main}') # every prediction of the pre-registration is scored ids = {p['id'] for p in r['predictions']} if ids != {p[0] for p in PREDICTIONS}: problems.append(f'unscored predictions: {sorted({p[0] for p in PREDICTIONS} - ids)}') out = {'problems': problems, 'notes': notes} (DATA / 'validation.json').write_text(json.dumps(out, ensure_ascii=False, indent=1)) for x in problems: log('PROBLEM', x) for x in notes: log('note', x) log('validation', 'failed' if problems else 'passed') return not problems # ------------------------------------------------------------------ export def _round(x, digits=4): if isinstance(x, dict): return {k: _round(v, digits) for k, v in x.items()} if isinstance(x, list): return [_round(v, digits) for v in x] if isinstance(x, float): return round(x, digits) return x def export(): val = json.loads((DATA / 'validation.json').read_text()) if val['problems']: raise SystemExit('validation failed — run validate and resolve its problems first') r = json.loads((DATA / 'computed.json').read_text()) keep_q = lambda q: {k: q[k] for k in ('ratio', 'lo', 'hi', 'z')} # noqa: E731 slim = lambda v: {k: v[k] for k in ('n_used', 'sign_obs', 'sign_exp', 'sign_ratio', 'top_sign', # noqa: E731 'bottom_sign', 'month_obs', 'month_exp', 'quarters', 'baseline')} out = { 'generated_by': 'scripts/research/athlete_birth_signs.py', 'signs': SIGNS, 'sign_start': SIGN_START, 'first_year': FIRST_YEAR, 'last_year': LAST_YEAR, 'groups': r['groups'] | {'football_women': 'Women’s football', 'football': 'Association football'}, 'sports_keys': list(SPORTS), 'others_keys': list(OTHERS), 'tally': r['tally'], 'country_from': r['country_from'], 'baseline_mix': r['baseline_mix'], 'n_athletes': r['n_athletes'], 'n_athletes_country': r['n_athletes_country'], 'n_others': r['n_others'], 'sports': {k: slim(v) for k, v in r['sports'].items()}, 'eras': {k: {'sport': v['sport'], 'country': v['country'], 'years': v['years'], **slim(v)} for k, v in r['eras'].items()}, 'experiments': {k: [{'years': p['years'], 'n': p['n'], **{f'q{c}': keep_q(p[f'q{c}']) for c in (JAN, APR, 5, AUG, SEP)}} for p in pts] for k, pts in r['experiments'].items()}, 'lahman': {k: {'month_corr': v['month_corr'], 'lahman_n': v['lahman']['n_used'], 'wikidata_n': v['wikidata']['n_used'], 'lahman_q': v['lahman']['quarters'], 'wikidata_q': v['wikidata']['quarters']} for k, v in r['lahman'].items()}, 'collapse': r['collapse'], 'fake_cutoffs': r['fake_cutoffs'], 'fake_cutoffs_other': r['fake_cutoffs_other'], 'fake_cutoffs_blind': r['fake_cutoffs_blind'], 'placebo_official': r['placebo_official'], 'fame': r['fame'], 'predictions': r['predictions'], 'robustness': {mode: [{k: p.get(k) for k in ('id', 'country', 'group', 'ratio', 'hit_direction', 'hit_sign', 'hit')} for p in preds] for mode, preds in r['robustness'].items()}, 'cutoff_sources': r['cutoff_sources'], 'lookup': {sport: {c: {era: {'n': v['n'], 'sign_obs': v['sign_obs'], 'sign_exp': v['sign_exp']} for era, v in eras.items()} for c, eras in cells.items()} for sport, cells in r['lookup'].items()}, } JSON_OUT.write_text(json.dumps(_round(out), ensure_ascii=False, separators=(',', ':')) + '\n') # dataset: observed and expected counts by sign and by month (no individual people) rows = [] for sport, cells in r['lookup'].items(): for ctry, eras in cells.items(): for era, v in eras.items(): y0, y1 = LOOKUP_ERAS[era] for unit, obs, exp in (('sign', v['sign_obs'], v['sign_exp']), ('month', v['month_obs'], v['month_exp'])): labels = SIGNS if unit == 'sign' else MONTHS_EN for lab, o, e in zip(labels, obs, exp): rows.append([sport, ctry, f'{y0}-{y1}', unit, lab, o, f'{e:.2f}', f'{o / e:.4f}' if e else '', v['baseline'].get('official', 0) + v['baseline'].get('nearest year', 0)]) CSV_OUT.parent.mkdir(parents=True, exist_ok=True) with CSV_OUT.open('w', newline='') as fh: w = csv.writer(fh, lineterminator='\n') w.writerow(['group', 'country', 'birth_years', 'unit', 'label', 'observed', 'expected', 'ratio', 'official_baseline_share']) w.writerows(rows) if SCRIPT_COPY: shutil.copyfile(__file__, SCRIPT_COPY) log(f'wrote {JSON_OUT} ({JSON_OUT.stat().st_size // 1024} KB), {CSV_OUT} ({CSV_OUT.stat().st_size // 1024} KB)') if __name__ == '__main__': steps = {'fetch': fetch, 'compute': compute, 'validate': validate, 'export': export} if len(sys.argv) != 2 or sys.argv[1] not in steps: raise SystemExit(f'usage: {sys.argv[0]} {"|".join(steps)}') ok = steps[sys.argv[1]]() sys.exit(1 if ok is False else 0)