"""Coordenador do demonstrador federado do Cledon. O coordenador NUNCA lê os arquivos dos hospitais. Ele só envia pedidos ao nó local de cada hospital (no.py, executado como processo separado) e recebe agregados. Gera saida/relatorio.html com as quatro telas da proposta. Uso: python coordenador.py (depois de gerar_dados_ficticios.py) """ import datetime as dt import hashlib import html import json import subprocess import sys from pathlib import Path import numpy as np from scipy import stats RAIZ = Path(__file__).resolve().parent PROTOCOLO = json.loads((RAIZ / "protocolo" / "protocolo-piloto-esofago.json").read_text()) SAIDA = RAIZ / "saida" NOMES = {"A": "Hospital A (Sul, simulado)", "B": "Hospital B (Minas Gerais, simulado)", "C": "Hospital C (Bahia, simulado; teste externo)"} ROTULOS = {"intercepto": "Intercepto", "idade_10anos": "Idade (+10 anos)", "sexo_feminino": "Sexo feminino", "ct3_4": "Tumor cT3–4", "cn_positivo": "Linfonodo cN+", "terco_inferior": "Terço inferior", "ecog_1_ou_mais": "ECOG ≥ 1", "albumina_g_dl": "Albumina (+1 g/dL)", "hemoglobina_g_dl": "Hemoglobina (+1 g/dL)", "log_rnl": "Razão neutrófilo/linfócito (log)", "perda_peso_pct": "Perda de peso (+1 ponto %)"} def hash_protocolo(p): return hashlib.sha256(json.dumps(p, sort_keys=True, ensure_ascii=False).encode()).hexdigest() def pedir(hospital, operacao, parametros=None, protocolo=None): pedido = {"protocolo": protocolo or PROTOCOLO, "operacao": operacao, "parametros": parametros or {}} r = subprocess.run([sys.executable, str(RAIZ / "no.py"), str(RAIZ / "hospitais" / hospital)], input=json.dumps(pedido, ensure_ascii=False), capture_output=True, text=True, check=True) resposta = json.loads(r.stdout) with open(SAIDA / "auditoria_coordenador.jsonl", "a") as f: f.write(json.dumps({"quando": dt.datetime.now().isoformat(timespec="seconds"), "hospital": hospital, "operacao": operacao, "recusado": resposta.get("recusado", "")}, ensure_ascii=False) + "\n") return resposta def ajustar_federado(hospitais, modelo): """Regressão logística por Newton–Raphson: cada hospital devolve só gradiente e hessiana da sua base.""" p = len(PROTOCOLO["modelos"][modelo]) + 1 b = np.zeros(p) for rodada in range(1, 51): g, H, ll, n, ev = np.zeros(p), np.zeros((p, p)), 0.0, 0, 0 for h in hospitais: r = pedir(h, "logistica_passo", {"modelo": modelo, "beta": b.tolist()}) if "recusado" in r: raise RuntimeError(f"{h}: {r['recusado']}") g += np.array(r["gradiente"]) H += np.array(r["hessiana"]) ll += r["logverossimilhanca"] n += r["n"] ev += r["eventos"] passo = np.linalg.solve(H, g) b = b + passo if np.abs(passo).max() < 1e-8: break # hessiana no ponto final, para os erros-padrão H = sum(np.array(pedir(h, "logistica_passo", {"modelo": modelo, "beta": b.tolist()})["hessiana"]) for h in hospitais) ep = np.sqrt(np.diag(np.linalg.inv(H))) return {"beta": b, "ep": ep, "n": n, "eventos": ev, "rodadas": rodada, "termos": ["intercepto"] + PROTOCOLO["modelos"][modelo]} def tab(cab, linhas, cls="num"): th = "".join(f"{c}" for c in cab) trs = "".join("" + "".join(f"{v}" for v in l) + "" for l in linhas) return f'
{th}{trs}
' def f2(x, c=2): return f"{x:.{c}f}".replace(".", ",") def main(): for arq in ("auditoria_coordenador.jsonl",): (SAIDA / arq).write_text("") hosp = ["A", "B", "C"] h_prot = hash_protocolo(PROTOCOLO) # Tela 1 — rede e qualidade qual = {h: pedir(h, "qualidade") for h in hosp} # Objetivo secundário — exposições documentadas, com supressão de células pequenas expos = ["tabagismo_atual", "alcool_pesado", "chimarrao_quente", "cachaca_regular"] desc = {h: pedir(h, "descritivo", {"variaveis": expos + ["albumina_g_dl"]}) for h in hosp} # Tela 3 — tentativas que devem ser bloqueadas adulterado = json.loads(json.dumps(PROTOCOLO)) adulterado["variaveis_aprovadas"]["cep_ficticio"] = "categórica" bloqueios = [ ("Exportar a lista de pacientes", pedir("A", "exportar_linhas")), ("Pedir o CEP dos pacientes (variável fora do protocolo)", pedir("B", "descritivo", {"variaveis": ["cep_ficticio"]})), ("Usar uma versão do protocolo alterada sem nova aprovação", pedir("C", "descritivo", {"variaveis": ["sexo"]}, adulterado)), ] # Desenvolvimento federado em A e B; teste externo em C dev, ext = PROTOCOLO["desenvolvimento"], PROTOCOLO["teste_externo"] fits = {m: ajustar_federado(dev, m) for m in ("completo", "simples")} # Congelar os modelos antes do teste externo: o hospital C registra cada um e só aceita avaliá-lo uma vez congelados = {} pasta_mc = SAIDA / "modelos_congelados" pasta_mc.mkdir(exist_ok=True) for m, f in fits.items(): mc = {"modelo": m, "termos": f["termos"], "beta": [float(x) for x in f["beta"]], "protocolo": h_prot, "avaliacao": PROTOCOLO["avaliacao"]} (pasta_mc / f"{m}.json").write_text(json.dumps(mc, ensure_ascii=False, indent=1)) for h in ext: # passo institucional simulado (ver aprovacao_local.py) subprocess.run([sys.executable, str(RAIZ / "aprovacao_local.py"), str(RAIZ / "hospitais" / h), str(pasta_mc / f"{m}.json")], check=True, capture_output=True) congelados[m] = mc aval = {m: {} for m in fits} for m in fits: for h in hosp: par = dict(PROTOCOLO["avaliacao"]) par.update({"modelo_congelado": congelados[m]} if h in ext else {"modelo": m, "beta": fits[m]["beta"].tolist()}) aval[m][h] = pedir(h, "avaliar", par) if "recusado" in aval[m][h]: raise RuntimeError(f"{h}: {aval[m][h]['recusado']} (rode gerar_dados_ficticios.py antes de repetir)") bloqueios += [ ("Treinar o modelo no hospital reservado para teste externo", pedir("C", "logistica_passo", {"modelo": "completo", "beta": [0.0] * len(fits["completo"]["beta"])})), ("Avaliar no teste externo um modelo que não foi congelado e registrado", pedir("C", "avaliar", {"modelo": "completo", "beta": (fits["completo"]["beta"] + 0.1).tolist()})), ("Repetir a avaliação externa do mesmo modelo congelado", pedir("C", "avaliar", {"modelo_congelado": congelados["completo"], "semente": 7, "mesmos_casos_que": "completo"})), ] resultado = {"protocolo": PROTOCOLO["id"], "versao": PROTOCOLO["versao"], "hash_protocolo": h_prot, "qualidade": qual, "descritivo": desc, "bloqueios": [(a, r.get("recusado")) for a, r in bloqueios], "modelos": {m: {"termos": f["termos"], "beta": f["beta"].round(6).tolist(), "ep": f["ep"].round(6).tolist(), "n": f["n"], "eventos": f["eventos"], "rodadas": f["rodadas"]} for m, f in fits.items()}, "avaliacao": aval} (SAIDA / "resultado.json").write_text(json.dumps(resultado, ensure_ascii=False, indent=1)) relatorio(resultado, fits, qual, desc, bloqueios, aval) print("ok:", SAIDA / "relatorio.html") def relatorio(res, fits, qual, desc, bloqueios, aval): verdade = json.loads((SAIDA / "efeitos_plantados.json").read_text()) z = stats.norm.ppf(0.975) hosp = ["A", "B", "C"] papel = {"A": "desenvolvimento", "B": "desenvolvimento", "C": "teste externo"} t_rede = tab(["Hospital", "Papel", "Pacientes", "Faltantes: albumina", "RNL", "Perda de peso", "Chimarrão", "Cachaça"], [[NOMES[h], papel[h], qual[h]["n_total"]] + [qual[h]["faltantes"][v] for v in ("albumina_g_dl", "rnl", "perda_peso_pct", "chimarrao_quente", "cachaca_regular")] for h in hosp]) pr = PROTOCOLO t_prot = tab(["Item", "Conteúdo"], [ ["Pergunta", html.escape(pr["pergunta"])], ["Objetivo secundário", html.escape(pr["objetivo_secundario"])], ["Desfecho", html.escape(pr["desfecho"]["definicao"])], ["Data da previsão", pr["data_da_previsao"]], ["População-alvo", html.escape(pr["populacao_alvo"])], ["Variáveis aprovadas", ", ".join(k for k in pr["variaveis_aprovadas"])], ["Modelo simples (comparador)", ", ".join(ROTULOS[t] for t in pr["modelos"]["simples"])], ["Modelo completo", ", ".join(ROTULOS[t] for t in pr["modelos"]["completo"])], ["Avaliação aprovada", "Mesmos casos completos do modelo completo; semente e 1.000 reamostragens fixadas antes do teste externo."], ["Controles de saída", f"células < {pr['controles_de_saida']['celula_minima']} suprimidas (com supressão complementar); " f"modelo só com ≥ {pr['controles_de_saida']['n_minimo_para_modelo']} casos completos; no máximo " f"{pr['controles_de_saida']['maximo_de_rodadas_de_modelo_por_hospital']} rodadas de modelo por hospital; nenhuma linha individual"], ["Versão e impressão digital", f"{pr['versao']} · SHA-256 {res['hash_protocolo'][:16]}…"]], cls="txt") rot_exp = {"tabagismo_atual": "Tabagismo atual", "alcool_pesado": "Álcool (consumo pesado)", "chimarrao_quente": "Chimarrão quente", "cachaca_regular": "Cachaça regular"} linhas_exp = [] for v, rv in rot_exp.items(): for nv in ("sim", "nao", "(não registrado)"): linhas_exp.append([rv, {"sim": "sim", "nao": "não", "(não registrado)": "não registrado"}[nv]] + [desc[h]["variaveis"][v].get(nv, 0) for h in hosp]) t_exp = tab(["Exposição documentada", "Registro"] + [NOMES[h].split(" (")[0] for h in hosp], linhas_exp) t_bloq = tab(["Tentativa", "Resposta do hospital"], [[a, f"⛔ {html.escape(r.get('recusado', 'NÃO BLOQUEADO'))}"] for a, r in bloqueios], cls="txt") f = fits["completo"] lin = [] for t, b, e in zip(f["termos"], f["beta"], f["ep"]): if t == "intercepto": continue lin.append([ROTULOS[t], f2(np.exp(b)), f"{f2(np.exp(b - z * e))}–{f2(np.exp(b + z * e))}", f2(np.exp(verdade[t]))]) cobre = sum(1 for t, b, e in zip(f["termos"], f["beta"], f["ep"]) if t != "intercepto" and b - z * e <= verdade[t] <= b + z * e) t_coef = tab(["Variável", "Razão de chances estimada", "IC 95%", "Valor plantado nos dados fictícios"], lin) lin2, lin3 = [], [] for h in hosp: for m, rm in (("simples", "simples (cT, cN)"), ("completo", "completo")): a = aval[m][h] nome = f"{h} · {papel[h]}" lin2.append([nome, rm, a["n"], a["eventos"], f"{f2(a['auc'])} ({f2(a['auc_ic95'][0])}–{f2(a['auc_ic95'][1])})"]) lin3.append([nome, rm, f2(a["brier"], 3), f2(a["observado_sobre_esperado"]), f2(a["inclinacao_calibracao"])]) t_aval = (tab(["Hospital", "Modelo", "Casos", "Respostas completas", "AUC (IC 95%)"], lin2) + "

Calibração:

" + tab(["Hospital", "Modelo", "Brier", "Observado/esperado", "Inclinação de calibração"], lin3)) aud = [json.loads(l) for l in (SAIDA / "auditoria_coordenador.jsonl").read_text().splitlines() if l] n_rec = sum(1 for a in aud if a["recusado"]) corpo = f"""
DEMONSTRAÇÃO COM DADOS FICTÍCIOS · nenhum paciente real · os números abaixo testam o software, não são evidência clínica

Cledon · demonstrador da rede federada

Piloto simulado: câncer de esôfago, resposta ao tratamento neoadjuvante

Três hospitais simulados guardam seus próprios registros. O coordenador envia uma análise aprovada, cada hospital executa localmente e devolve apenas resultados agregados. Gerado em {dt.date.today().strftime('%d/%m/%Y')}.

1. Rede participante

Hospitais fictícios e qualidade das bases. Nenhuma lista de pacientes sai dos hospitais: abaixo estão apenas contagens, com células menores que {pr['controles_de_saida']['celula_minima']} suprimidas.

{t_rede}

2. Protocolo do estudo

{t_prot}

Cada hospital mantém a própria lista de protocolos aprovados, identificados pela impressão digital (SHA-256). Qualquer alteração no protocolo muda a impressão digital e exige nova aprovação.

3. Execução controlada

O coordenador fez {len(aud)} pedidos aos hospitais; {n_rec} foram recusados. Cada hospital registrou todos os pedidos na sua auditoria local. As tentativas abaixo foram feitas de propósito e precisavam ser bloqueadas. O papel de cada hospital (desenvolvimento ou teste externo) é imposto pelo próprio hospital, não só pelo coordenador; antes do teste externo, cada modelo é congelado e registrado no hospital C, que aceita avaliá-lo uma única vez. O conjunto de casos e os parâmetros de avaliação também são fixados pelo protocolo.

{t_bloq}

Objetivo secundário: exposições documentadas em prontuário, por hospital. Contagens com supressão de células pequenas e supressão complementar (para que a célula suprimida não possa ser recuperada pela diferença com o total).

{t_exp}

Faltantes e observados de cada variável são suprimidos em conjunto, em todas as operações: quando há menos de 5 faltantes, o número de observados também aparece como intervalo, para que uma consulta não desfaça a supressão de outra. Campos vazios ou com valor inválido contam como "não registrado" e não entram nos modelos.

Descrição de registros, não comparação de riscos: sem um grupo de comparação, estas contagens não dizem se uma exposição aumenta o risco. Em prontuários reais, "não registrado" costuma ser frequente e não significa "não exposto".

4. Resultado científico

Modelo desenvolvido de forma federada nos hospitais A e B ({f['n']} casos completos, {f['eventos']} respostas completas; {f['rodadas']} rodadas). Em cada rodada, cada hospital devolveu apenas um vetor de {len(f['termos'])} números (gradiente) e uma matriz {len(f['termos'])}×{len(f['termos'])} (hessiana). O resultado é idêntico ao de juntar as bases, sem juntá-las (verificado em testes.py).

{t_coef}

A coluna da direita mostra os efeitos inventados na geração dos dados. {cobre} dos {len(f['termos']) - 1} intervalos de 95% contêm o valor plantado (o esperado é cerca de 95% deles; com amostras pequenas, alguns ficam de fora por acaso). Isso mostra que o fluxo funciona; não diz nada sobre pacientes reais.

Comparação com o modelo simples e teste externo. O hospital C não participou do desenvolvimento. Os dois modelos são avaliados nos mesmos pacientes (casos completos do modelo completo). O desempenho em A e B é aparente, medido nos hospitais de desenvolvimento; não substitui validação interna com correção de otimismo.

{t_aval}

AUC: capacidade de ordenar quem responde e quem não responde (0,5 = acaso). Observado/esperado e inclinação de calibração próximos de 1 indicam previsões bem calibradas. Nos dados fictícios, o hospital C foi gerado com taxa de resposta diferente, para mostrar como o teste externo revela problemas de calibração que o desenvolvimento não mostra.

Limites desta demonstração

Arquivos: protocolo/protocolo-piloto-esofago.json, no.py (nó local), coordenador.py, testes.py, saida/resultado.json e as auditorias em hospitais/*/auditoria_local.jsonl.

""" pagina = f""" Cledon · Demonstrador federado {corpo.replace('
', '
')}
""" (SAIDA / "relatorio.html").write_text(pagina) if __name__ == "__main__": main()