"""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''
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
cledon.org · Proposta (PDF) · Relatório (PDF) ·
Como funciona · nó local ·
coordenador · testes ·
protocolo
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
- Dados fictícios demonstram o funcionamento do software, não eficácia clínica, distribuição real de pacientes ou segurança definitiva.
- Gradientes e hessianas podem vazar informação quando há poucos casos ou muitas rodadas; por isso há um mínimo de casos e um
limite de rodadas, mas isso não é uma garantia formal de privacidade. Num piloto real, o modelo de ameaças e controles adicionais
(agregação segura, privacidade diferencial) seriam avaliados com os hospitais.
- A supressão conjunta cobre faltantes e observados de cada variável; outras margens, como o número de casos completos
de cada modelo, ainda podem ser combinadas entre consultas. Num piloto real, todas as saídas seriam registradas e checadas em
conjunto antes da liberação.
- Os três "hospitais" rodam no mesmo computador, como processos separados. Num piloto real, cada nó ficaria na
infraestrutura do hospital, com autenticação, criptografia e aprovação local de cada versão do código.
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()