"""06. Implementación 1 del contraste (Python puro sobre la tabla sc de 05_screens.sql): AUC estratificada por cártel × año, IC95 bootstrap en dos etapas (órgano, licitación), p de permutación, Holm, leave-one-cartel-out, calibración y sensibilidades. Reglas: DEFINICIONES_PREVIAS.md §3-§5 (sellado ee85d2f5…). Semilla 20261021. Salidas: salidas/06_auc.md (agregado por cártel, sin empresas ni lotes) e interno/06_auc.json.""" import duckdb, random, json, time, math T0 = time.time() SEED, NBOOT, NPERM = 20261021, 2000, 2000 con = duckdb.connect('/c/interno/col.duckdb', read_only=True) COLS = ['id_canonical', 'licitacion_id', 'organo_clave', 'anio', 'clase', 'en_c', 'pnc', 's3', 's5', 's3_tramo', 'p_rango', 'p_sep', 'p_baja', 'p_rep', 'p_est', 'ute_rivales', 'pos_alim', 'pos_mil', 'pos_carr', 'm_alim', 'm_mil', 'm_carr', 'excluido', 'es_v2'] R = [dict(zip(COLS, r)) for r in con.execute(f"SELECT {', '.join(COLS)} FROM sc").fetchall()] print('filas', len(R), round(time.time() - T0), flush=True) CARTELES = [('alimentos', 'pos_alim', 'm_alim'), ('militar', 'pos_mil', 'm_mil'), ('carreteras', 'pos_carr', 'm_carr')] # candidato: (columna, población) ; población 'c' = PC con mercado >= 20 ; 'p' = P con PBL (solo PNC) CAND = {'S3': ('s3', 'c'), 'S5': ('s5', 'c'), 'RANGO': ('p_rango', 'c'), 'SEP': ('p_sep', 'c'), 'BAJA_COND': ('p_baja', 'c'), 'PNC': ('pnc', 'p'), 'REP_IGUAL': ('p_rep', 'c'), 'ESTAB': ('p_est', 'c'), 'UTE_RIVALES': ('ute_rivales', 'c')} def unidades(col, pob, carteles, filtro=None, solo_v2=False): """Lista de unidades (cartel, anio, organo, licitacion, valor, es_pos) del candidato en esos cárteles.""" out = [] for c, pc, mc in carteles: anios = set() pos, ctl = [], [] for r in R: v = r[col] if v is None or (pob == 'c' and not r['en_c']): continue if filtro and not filtro(r): continue if r[pc] == 1 and (not solo_v2 or r['es_v2']): pos.append(r); anios.add(r['anio']) elif r[mc] and not r['excluido']: ctl.append(r) for r in pos: out.append((c, r['anio'], r['organo_clave'], r['licitacion_id'], float(r[col]), 1)) for r in ctl: if r['anio'] in anios: out.append((c, r['anio'], r['organo_clave'], r['licitacion_id'], float(r[col]), 0)) return out def preparar(us): """Estratos ordenados por valor: lista de listas de índices de unidad, más etiquetas.""" est = {} for i, u in enumerate(us): est.setdefault((u[0], u[1]), []).append(i) return [sorted(ix, key=lambda i: us[i][4]) for ix in est.values()] def auc(us, estr, lab=None, w=None): """AUC estratificada (Σ U_s / Σ n1 n0), empates ½, pesos opcionales; lab: etiquetas (por defecto las de us).""" U = N = 0.0 for ix in estr: ctl_debajo = 0.0; sp = sc_ = 0.0; Us = 0.0 k = 0; n = len(ix) while k < n: v = us[ix[k]][4]; gp = gc = 0.0 while k < n and us[ix[k]][4] == v: i = ix[k]; wi = 1.0 if w is None else w[i] if (us[i][5] if lab is None else lab[i]): gp += wi else: gc += wi k += 1 Us += gp * (ctl_debajo + 0.5 * gc) ctl_debajo += gc; sp += gp; sc_ += gc U += Us; N += sp * sc_ return (U / N if N > 0 else None), N def resumen(us): n1 = sum(1 for u in us if u[5]); n0 = len(us) - n1 return n1, n0, len({u[3] for u in us if u[5]}), len({u[2] for u in us if u[5]}) rng = random.Random(SEED) def pesos_boot(us_union_keys, nrep): """us_union_keys: lista de (organo, licitacion) de todas las unidades del grupo. Devuelve generador de dict licitación→peso.""" org = {} for o, l in us_union_keys: org.setdefault(o, set()).add(l) orgs = sorted(org, key=lambda x: (x is None, str(x))) lics = {o: sorted(org[o], key=str) for o in orgs} for _ in range(nrep): wl = {} for _o in range(len(orgs)): o = orgs[rng.randrange(len(orgs))] L = lics[o] for _l in range(len(L)): l = L[rng.randrange(len(L))] wl[l] = wl.get(l, 0) + 1 yield wl def percentil(xs, q): xs = sorted(x for x in xs if x is not None) if not xs: return None k = (len(xs) - 1) * q; f = math.floor(k); c = math.ceil(k) return xs[f] + (xs[c] - xs[f]) * (k - f) def bloque(grupos, nombre_grupo, filtro=None, solo_v2=False, cands=None, nboot=NBOOT): """AUC puntual y bootstrap de cada candidato para un grupo de cárteles (uno o los tres).""" cands = cands or list(CAND) U = {k: unidades(CAND[k][0], CAND[k][1], grupos, filtro, solo_v2) for k in cands} E = {k: preparar(U[k]) for k in cands} res = {} for k in cands: a, N = auc(U[k], E[k]) res[k] = dict(auc=a, pares=N, **dict(zip(['n_pos', 'n_ctl', 'lics_pos', 'org_pos'], resumen(U[k]))), boot=[]) claves = sorted({(u[2], u[3]) for k in cands for u in U[k]}, key=str) for wl in pesos_boot(claves, nboot): for k in cands: w = [wl.get(u[3], 0) for u in U[k]] a, _ = auc(U[k], E[k], w=w) res[k]['boot'].append(a) for k in cands: b = res[k].pop('boot') res[k]['lo'] = percentil(b, 0.025); res[k]['hi'] = percentil(b, 0.975); res[k]['boot_validas'] = sum(x is not None for x in b) print('bloque', nombre_grupo, round(time.time() - T0), flush=True) return res, U, E out = {} # 1. por cártel y en conjunto for c in CARTELES: out[c[0]], _, _ = bloque([c], c[0]) out['conjunto'], Uc, Ec = bloque(CARTELES, 'conjunto') # 2. permutación dentro de estrato (conjunto) y Holm perm = {} for k in CAND: us, estr = Uc[k], Ec[k] obs = out['conjunto'][k]['auc'] if obs is None: perm[k] = None; continue lab = [u[5] for u in us] ge = 0 for _ in range(NPERM): lp = lab[:] for ix in estr: vals = [lp[i] for i in ix]; rng.shuffle(vals) for i, v in zip(ix, vals): lp[i] = v a, _ = auc(us, estr, lab=lp) ge += a >= obs - 1e-12 perm[k] = (1 + ge) / (1 + NPERM) orden = sorted([k for k in CAND if perm[k] is not None], key=lambda k: perm[k]) holm = {} m = len(orden); maxp = 0 for j, k in enumerate(orden): adj = min(1.0, (m - j) * perm[k]); maxp = max(maxp, adj); holm[k] = maxp print('permutación', round(time.time() - T0), flush=True) # 3. leave-one-cartel-out (selección del candidato en los otros dos) loco = [] for c in CARTELES: otros = [x for x in CARTELES if x != c] best, ba = None, -1 for k in CAND: us = unidades(CAND[k][0], CAND[k][1], otros); a, _ = auc(us, preparar(us)) if a is not None and a > ba: best, ba = k, a loco.append(dict(fuera=c[0], elegido=best, auc_otros=ba, auc_fuera=out[c[0]][best]['auc'])) # 4. criterio veredicto = {} for k in CAND: pasa = [c[0] for c in CARTELES if (out[c[0]][k]['auc'] or 0) >= 0.65] a = len(pasa) >= 2 b = (out['conjunto'][k]['lo'] or 0) > 0.5 if k == 'S3': f = a and b; cd = None else: cc = holm.get(k, 1) is not None and holm.get(k, 1) < 0.05 dd = sum(1 for x in loco if x['auc_fuera'] is not None and x['auc_fuera'] >= 0.65) >= 2 f = a and b and cc and dd; cd = (cc, dd) veredicto[k] = dict(carteles_auc065=pasa, a=a, b=b, c_d=cd, funciona=f) # 5. calibración de S3 (deciles de pct_s3 en positivas + controles de cada cártel) pct = {r['id_canonical']: None for r in []} P3 = {r['licitacion_id']: 0 for r in []} sc3 = {} for r in con.execute("SELECT id_canonical, pct_s3 FROM sc WHERE pct_s3 IS NOT NULL").fetchall(): sc3[r[0]] = r[1] cal = {} for c, pc, mc in CARTELES: filas = [(sc3[r['id_canonical']], 1 if r[pc] == 1 else 0) for r in R if r['id_canonical'] in sc3 and r['en_c'] and (r[pc] == 1 or (r[mc] and not r['excluido']))] anios = {r['anio'] for r in R if r[pc] == 1 and r['id_canonical'] in sc3} filas = [f for f, r in zip(filas, [r for r in R if r['id_canonical'] in sc3 and r['en_c'] and (r[pc] == 1 or (r[mc] and not r['excluido']))]) if r['anio'] in anios] dec = [] for d in range(10): g = [y for x, y in filas if d / 10 <= x < (d + 1) / 10 or (d == 9 and x >= 1)] dec.append((len(g), sum(g))) base = sum(y for _, y in filas) / len(filas) if filas else None top = [y for x, y in filas if x >= 0.8] cal[c] = dict(deciles=dec, base=base, tasa_q5=(sum(top) / len(top) if top else None), n=len(filas)) # 6. sensibilidades (S3, con bootstrap) sens = {} sens['i_solo_V2'] = {g: bloque([c], c[0], solo_v2=True, cands=['S3'])[0]['S3'] for g, c in zip(['alimentos', 'militar', 'carreteras'], CARTELES)} sens['i_solo_V2']['conjunto'] = bloque(CARTELES, 'conj', solo_v2=True, cands=['S3'])[0]['S3'] f2 = lambda r: r['clase'] not in ('basado', 'nsp', 'emergencia') sens['ii_sin_basados_nsp_emerg'] = {c[0]: bloque([c], c[0], filtro=f2, cands=['S3'])[0]['S3'] for c in CARTELES} sens['ii_sin_basados_nsp_emerg']['conjunto'] = bloque(CARTELES, 'conj', filtro=f2, cands=['S3'])[0]['S3'] CAND['S3_tramo'] = ('s3_tramo', 'c') sens['iii_mercado_x_tramo'] = {c[0]: bloque([c], c[0], cands=['S3_tramo'])[0]['S3_tramo'] for c in CARTELES} sens['iii_mercado_x_tramo']['conjunto'] = bloque(CARTELES, 'conj', cands=['S3_tramo'])[0]['S3_tramo'] f4 = lambda r: r['p_baja'] is not None sens['iv_con_los_tres'] = {c[0]: bloque([c], c[0], filtro=f4, cands=['S3'])[0]['S3'] for c in CARTELES} sens['iv_con_los_tres']['conjunto'] = bloque(CARTELES, 'conj', filtro=f4, cands=['S3'])[0]['S3'] # (v) unidad licitación: S3 medio por (cártel, licitación, etiqueta); año = mínimo de sus lotes sens['v_licitacion'] = {} for nombre, grupos in [(c[0], [c]) for c in CARTELES] + [('conjunto', CARTELES)]: us = unidades('s3', 'c', grupos) agg = {} for u in us: k = (u[0], u[3], u[5]); a = agg.setdefault(k, [u[1], u[2], []]); a[0] = min(a[0], u[1]); a[2].append(u[4]) ul = [(k[0], v[0], v[1], k[1], sum(v[2]) / len(v[2]), k[2]) for k, v in agg.items()] a, N = auc(ul, preparar(ul)) sens['v_licitacion'][nombre] = dict(auc=a, n_pos=sum(u[5] for u in ul), n_ctl=sum(1 - u[5] for u in ul)) res = dict(por_cartel={c[0]: out[c[0]] for c in CARTELES}, conjunto=out['conjunto'], perm=perm, holm=holm, loco=loco, veredicto=veredicto, calibracion=cal, sensibilidades=sens, semilla=SEED, nboot=NBOOT, nperm=NPERM) json.dump(res, open('/c/interno/06_auc.json', 'w'), indent=1, default=str) def f(x, d=3): return '—' if x is None else f"{x:.{d}f}".replace('.', ',') L = ["# 06 · AUC de los screens de capa 1 frente a los lotes de tres cárteles sancionados (implementación 1)\n", f"Pre-registro `DEFINICIONES_PREVIAS.md` (md5 ee85d2f5…, 21-oct 19:08:55). Semilla {SEED}; bootstrap {NBOOT} réplicas en dos etapas (órgano, licitación); " f"permutación {NPERM} dentro de estrato cártel × año. Agregado por cártel: sin empresas ni lotes. Tiempo {round(time.time()-T0)} s.\n", "\n## 1. AUC estratificada por cártel y en conjunto (IC95 bootstrap)\n", "| Candidato | Alimentos | Militar | Carreteras | Conjunto | p perm. (conjunto) | p Holm |", "|---|---|---|---|---|---|---|"] for k in [x for x in CAND if x != 'S3_tramo']: cel = [] for g in ['alimentos', 'militar', 'carreteras', 'conjunto']: d = out[g][k] cel.append(f"{f(d['auc'])} [{f(d['lo'])}; {f(d['hi'])}] ({d['n_pos']}/{d['n_ctl']})") L.append(f"| {k} | " + " | ".join(cel) + f" | {f(perm[k], 4)} | {f(holm.get(k), 4)} |") L.append("\nEntre paréntesis: lotes positivos / lotes de control del candidato (los estratos sin los dos grupos no aportan pares).\n") L.append("\n## 2. Positivas por cártel (S3): licitaciones y órganos\n") for g in ['alimentos', 'militar', 'carreteras', 'conjunto']: d = out[g]['S3']; L.append(f"- {g}: {d['n_pos']} lotes positivos de {d['lics_pos']} licitaciones y {d['org_pos']} órganos; {d['n_ctl']} controles; {d['boot_validas']} réplicas válidas.") L.append("\n## 3. Leave-one-cartel-out (se elige el candidato con mayor AUC en los otros dos)\n") L.append("| Cártel fuera | Elegido | AUC en los otros dos | AUC en el de fuera |"); L.append("|---|---|---|---|") for x in loco: L.append(f"| {x['fuera']} | {x['elegido']} | {f(x['auc_otros'])} | {f(x['auc_fuera'])} |") L.append("\n## 4. Criterio pre-registrado\n") L.append("| Candidato | Cárteles con AUC ≥ 0,65 | (a) ≥ 2 de 3 | (b) LB conjunto > 0,50 | (c) Holm / (d) LOCO | Funciona |"); L.append("|---|---|---|---|---|---|") for k, v in veredicto.items(): L.append(f"| {k} | {', '.join(v['carteles_auc065']) or '—'} | {'sí' if v['a'] else 'no'} | {'sí' if v['b'] else 'no'} | " f"{'—' if v['c_d'] is None else ('sí' if v['c_d'][0] else 'no') + ' / ' + ('sí' if v['c_d'][1] else 'no')} | **{'sí' if v['funciona'] else 'no'}** |") L.append("\n## 5. Calibración de S3 (percentil de S3 dentro del mercado; % de positivas por decil)\n") L.append("| Cártel | base | " + " | ".join(f"d{d+1}" for d in range(10)) + " | tasa en pct ≥ 0,8 | razón q5/base |"); L.append("|---|---|" + "---|" * 12) for c, v in cal.items(): L.append(f"| {c} ({v['n']}) | {f(v['base'])} | " + " | ".join(f"{f(p/n if n else None, 3)} ({n})" for n, p in v['deciles']) + f" | {f(v['tasa_q5'])} | {f(v['tasa_q5']/v['base'] if v['base'] and v['tasa_q5'] is not None else None, 2)} |") L.append("\n## 6. Sensibilidades declaradas (S3; no deciden)\n") L.append("| Sensibilidad | Alimentos | Militar | Carreteras | Conjunto |"); L.append("|---|---|---|---|---|") for s, v in sens.items(): cel = [] for g in ['alimentos', 'militar', 'carreteras', 'conjunto']: d = v.get(g, {}) cel.append(f"{f(d.get('auc'))}" + (f" [{f(d.get('lo'))}; {f(d.get('hi'))}]" if 'lo' in d else '') + f" ({d.get('n_pos')}/{d.get('n_ctl')})") L.append(f"| {s} | " + " | ".join(cel) + " |") open('/c/salidas/06_auc.md', 'w').write("\n".join(L) + "\n") print('ok', round(time.time() - T0))