Mortalidade infantil no Brasil: neonatal vs. pós-neonatal em cada estado (com Julia e MicroSUS.jl)

Como construí, em Julia, um gráfico de barras empilhadas comparando a mortalidade neonatal e pós-neonatal em cada estado brasileiro, usando dados do SIM e do SINASC via MicroSUS.jl.

Um óbito no terceiro dia de vida e um óbito no oitavo mês entram na mesma estatística de mortalidade infantil — mas contam histórias muito diferentes. Separei as duas em Julia, estado por estado.

Gráfico de barras empilhadas comparando a taxa de mortalidade neonatal e pós-neonatal em cada estado brasileiro em 2023

A taxa de mortalidade infantil — a proporção de nascidos vivos que morrem antes de completar um ano — é um dos indicadores mais usados para medir a qualidade de um sistema de saúde. Mas o número sozinho esconde histórias muito diferentes: um óbito no terceiro dia de vida costuma apontar para problemas de gestação, parto e cuidado neonatal; um óbito no oitavo mês costuma apontar para infecções, saneamento e acesso continuado a serviços de saúde.

Foi para separar essas duas histórias que fiz o gráfico acima em Julia, usando o pacote MicroSUS.jl para baixar e ler diretamente os microdados do SUS. Neste post, mostro o código completo e como interpretar o resultado.

1) Neonatal e pós-neonatal: por que separar

A literatura de saúde pública divide o primeiro ano de vida em duas janelas:

  • Neonatal (0 a 27 dias completos): domina por causas ligadas à gestação e ao parto — prematuridade, malformações congênitas, asfixia, infecções adquiridas no parto. Reflete principalmente a qualidade do pré-natal e da assistência ao parto.
  • Pós-neonatal (28 dias a 11 meses): domina por causas ligadas ao ambiente em que a criança vive depois de alta — pneumonia, diarreia, desnutrição, acidentes. Reflete mais saneamento, renda e acesso continuado a serviços de saúde.

Um estado pode ter mortalidade infantil total parecida com outro e, ainda assim, apontar para problemas completamente diferentes conforme essa proporção. É esse contraste que o gráfico tenta deixar visível.

2) As fontes: SIM e SINASC via MicroSUS.jl

Os dados vêm de dois sistemas do Ministério da Saúde:

  • SIM (Sistema de Informações sobre Mortalidade): um registro por óbito, com a idade da criança ao morrer.
  • SINASC (Sistema de Informações sobre Nascidos Vivos): um registro por nascimento vivo, usado como denominador.

Em vez de baixar manualmente os arquivos .dbc do DATASUS, usei o MicroSUS.jl, que expõe duas funções simples:

using MicroSUS

caminho = baixar(:sim, "SP"; ano = 2023)     # baixa (e cacheia) o SIM de SP em 2023
tabela  = ler(caminho; colunas = [:IDADE, :CODMUNRES])

O pacote cuida do cache local (por isso os logs [ Info: cache: ...DOAC2023.dbc, um por UF, no meu terminal) e devolve os dados como uma tabela particionada — nada precisa ser materializado inteiro em memória.

3) O detalhe que decide tudo: o campo IDADE do SIM

A parte mais delicada do código não é estatística, é decodificação de formato. No SIM, a idade ao óbito vem em um campo de 3 dígitos: o primeiro dígito é a unidade, os outros dois são o valor.

1º dígito Unidade
0 minutos
1 horas
2 dias
3 meses
4 anos
9 ignorada

Um óbito com "205" significa “5 dias” (neonatal); "302" significa “2 meses” (pós-neonatal). A fronteira dos 28 dias cai bem no meio da unidade “dias”, então a classificação precisa comparar o valor, não só a unidade:

function faixa_obito_infantil(idade::AbstractString)
    t = strip(idade)
    (length(t) != 3 || !all(isdigit, t)) && return :fora
    u = t[1] - '0'
    v = 10 * (t[2] - '0') + (t[3] - '0')
    (u == 0 || u == 1) && return :neonatal            # minutos, horas
    u == 2 && return v < 28 ? :neonatal : :posneonatal
    u == 3 && return v == 0 ? :neonatal : :posneonatal
    return :fora
end
faixa_obito_infantil(::Missing) = :fora

Um caso ambíguo de propósito: "400" (zero anos completos, sem mais detalhe) é descartado — não dá para saber de que lado dos 28 dias o óbito cai, então incluí-lo enviesaria uma das duas faixas. Fica registrado na nota de rodapé do gráfico.

4) Agregando por UF de residência

Com a função de classificação pronta, o resto é contar. Um ponto importante: uso sempre CODMUNRES (município de residência), não o município de ocorrência do óbito — assim um parto complicado que termina num hospital de referência em outra cidade continua contando para a UF de origem da família.

function coletar(cfg::Config = Config())
    neo = Dict{String,Int}(uf => 0 for uf in UFS)
    pos = Dict{String,Int}(uf => 0 for uf in UFS)
    nvs = Dict{String,Int}(uf => 0 for uf in UFS)

    # óbitos infantis (SIM)
    for uf in UFS
        caminho = baixar(:sim, uf; ano = cfg.ano)
        isnothing(caminho) && (@warn "SIM ausente" uf; continue)

        tabela = ler(caminho; colunas = [:IDADE, :CODMUNRES],
                     schema = nothing, tamanho_lote = cfg.tamanho_lote)

        for lote in Tables.partitions(tabela)
            idades = Tables.getcolumn(lote, :IDADE)
            munis  = Tables.getcolumn(lote, :CODMUNRES)
            for i in eachindex(idades)
                (ismissing(idades[i]) || ismissing(munis[i])) && continue
                faixa = faixa_obito_infantil(String(idades[i]))
                faixa === :fora && continue
                sigla = uf_de_codmun(String(munis[i]))
                isnothing(sigla) && continue
                faixa === :neonatal ? (neo[sigla] += 1) : (pos[sigla] += 1)
            end
        end
    end

    # nascidos vivos (SINASC)
    for uf in UFS
        caminho = baixar(:sinasc, uf; ano = cfg.ano)
        isnothing(caminho) && (@warn "SINASC ausente" uf; continue)
        tabela = ler(caminho; colunas = [:CODMUNRES],
                     schema = nothing, tamanho_lote = cfg.tamanho_lote)
        for lote in Tables.partitions(tabela)
            for m in Tables.getcolumn(lote, :CODMUNRES)
                ismissing(m) && continue
                sigla = uf_de_codmun(String(m))
                isnothing(sigla) && continue
                nvs[sigla] += 1
            end
        end
    end

    resultado = Dict{String,NamedTuple}()
    for uf in UFS
        nv = nvs[uf]
        nv > cfg.minimo_nascimentos || continue
        resultado[uf] = (neo = neo[uf], pos = pos[uf], nascimentos = nv,
                         taxa_neo = neo[uf] / nv, taxa_pos = pos[uf] / nv)
    end
    return resultado
end

Repare que peço explicitamente schema = nothing na leitura do SIM: isso faz o IDADE chegar como texto cru ("205", "302"…), em vez de já convertido para anos — a conversão automática do MicroSUS.jl perderia justamente a granularidade fina que a fronteira dos 28 dias exige.

5) A figura: barras empilhadas com CairoMakie

Para a visualização, escolhi barras horizontais empilhadas — uma linha por estado, ordenadas pela taxa total decrescente, com a fatia neonatal (lilás) e pós-neonatal (rosa) uma ao lado da outra em cada barra.

function grafico_mortalidade(dados::AbstractDict, cfg::Config = Config())
    ufs = sort(collect(keys(dados));
               by = uf -> -(dados[uf].taxa_neo + dados[uf].taxa_pos))
    n     = length(ufs)
    nomes = [UF_NOME[uf] for uf in ufs]
    tneo  = [dados[uf].taxa_neo for uf in ufs]
    tpos  = [dados[uf].taxa_pos for uf in ufs]
    ys    = collect(n:-1:1)   # decrescente: o primeiro da lista fica no topo

    ax = Axis(fig[3, 1]; yticks = (ys, nomes))          # eixo com só os rótulos das UFs
    barplot!(ax, ys, tneo; direction = :x, color = BAR_NEO, width = 0.72)
    barplot!(ax, ys, tneo .+ tpos; direction = :x, fillto = tneo,
             color = BAR_POS, width = 0.72)
    # rótulos, cabeçalhos de coluna e rodapé omitidos por brevidade
end

Alguns detalhes que valeram a pena registrar como comentário no próprio script:

  • Ordenação manual do eixo Y. Em vez de confiar em yreversed, gero as posições já na ordem decrescente (n:-1:1) — mais previsível quando a figura também usa limits! explícito.
  • Coluna única no layout. Com duas colunas e Labels em tellwidth = false, as larguras ficam indeterminadas e o texto vaza da figura. Uma coluna só, com quebras de linha manuais, resolve.
  • Formatação pt-BR sem zeros à direita. Uma função pct pequena transforma 0.0068 em "0,68%" e 0.0100 em "1%", sem o zero decorativo.
  • Logo com fallback. A logo é baixada uma vez para o tempdir(); se a rede falhar, a figura não quebra — só cai para uma assinatura em texto.

6) O resultado no terminal

Antes da figura, o script imprime o ranking bruto, útil para conferir os números que vão para o gráfico:

Saída esperada

Roraima              neo  1.366%  pós  1.023%   (nv = 13105)
Amapá                neo  1.189%  pós  0.904%   (nv = 12948)
Sergipe              neo  1.193%  pós  0.655%   (nv = 29005)
Amazonas             neo  1.011%  pós  0.698%   (nv = 70453)
Acre                 neo  1.002%  pós  0.691%   (nv = 14473)
Pará                 neo  1.009%  pós  0.495%   (nv = 126152)
Piauí                neo  0.928%  pós  0.567%   (nv = 42144)
Maranhão             neo  0.976%  pós  0.508%   (nv = 97205)
Bahia                neo  1.056%  pós  0.425%   (nv = 170314)
Mato Grosso          neo  0.924%  pós  0.480%   (nv = 58553)
Alagoas              neo  0.885%  pós  0.471%   (nv = 46543)
Mato Grosso do Sul   neo  0.825%  pós  0.529%   (nv = 40233)
Rio de Janeiro       neo  0.898%  pós  0.448%   (nv = 176145)
Pernambuco           neo  0.844%  pós  0.477%   (nv = 116175)
Paraíba              neo  0.854%  pós  0.442%   (nv = 51531)
Tocantins            neo  0.843%  pós  0.428%   (nv = 23145)
Goiás                neo  0.842%  pós  0.404%   (nv = 91822)
Rondônia             neo  0.727%  pós  0.497%   (nv = 23921)
Ceará                neo  0.837%  pós  0.335%   (nv = 111091)
Espírito Santo       neo  0.776%  pós  0.383%   (nv = 52187)
São Paulo            neo  0.787%  pós  0.349%   (nv = 503910)
Minas Gerais         neo  0.792%  pós  0.336%   (nv = 233918)
Rio Grande do Norte  neo  0.751%  pós  0.365%   (nv = 39440)
Distrito Federal     neo  0.776%  pós  0.309%   (nv = 35551)
Paraná               neo  0.750%  pós  0.330%   (nv = 139836)
Rio Grande do Sul    neo  0.682%  pós  0.286%   (nv = 120974)
Santa Catarina       neo  0.629%  pós  0.278%   (nv = 96802)

7) Lendo o gráfico

Alguns padrões saltam aos olhos:

  • O Norte e o Nordeste ocupam o topo em quase todas as posições. Roraima, Amapá, Sergipe, Amazonas e Acre têm as cinco maiores taxas totais — e são também os cinco únicos estados com pós-neonatal acima de 0,6%, a fatia mais associada a condições de vida e acesso continuado a serviços de saúde.
  • A fatia neonatal domina em praticamente todo o país. Mesmo nos estados do Sul, com as menores taxas totais (Rio Grande do Sul e Santa Catarina fecham a lista), a parte neonatal segue maior que a pós-neonatal — sinal de que os ganhos dos últimos anos vieram mais de reduzir mortes evitáveis depois do primeiro mês do que de resolver a raiz perinatal.
  • A proporção entre as duas fatias varia mais do que o total. Bahia (neo 1,06% / pós 0,43%) e Rio de Janeiro (neo 0,90% / pós 0,45%) têm pós-neonatal quase idêntico — a diferença na taxa total entre os dois vem quase inteiramente da fatia neonatal, sugerindo que o gargalo da Bahia está mais concentrado no início da vida do que no restante do primeiro ano.
  • Distrito Federal quebra o padrão regional, com taxa total baixa (comparável a Paraná e Rio Grande do Norte) apesar de estar fora do Sul/Sudeste — reflexo, provavelmente, de renda e estrutura de saúde concentradas na capital federal.

8) Limitações

  • Atribuição por UF de residência, não de ocorrência — o objetivo é medir o resultado para a família, não a carga do hospital.
  • Óbitos com idade registrada só como "0 anos" (sem indicar dias/meses) foram descartados — não é possível saber de que lado da fronteira dos 28 dias eles caem, e incluí-los arbitrariamente distorceria a comparação entre as duas fatias.
  • Um único ano (2023). Taxas de mortalidade infantil sofrem flutuação amostral, especialmente em UFs menores (Roraima, Amapá, Acre têm poucos nascimentos): comparações estado a estado ficam mais confiáveis olhando para uma série de anos, não um único corte.

9) Código-fonte

O script completo — configuração, cores, dicionários de UF, classificação de idade, agregação e montagem da figura — está reproduzido nas seções acima. Ele depende de:

using Pkg
Pkg.add(["MicroSUS", "CairoMakie", "Tables"])

Referências

Pacotes Julia:

Fontes dos dados:

  • Ministério da Saúde — SIM (Sistema de Informações sobre Mortalidade) e SINASC (Sistema de Informações sobre Nascidos Vivos), ano de referência 2023.

Nota Importante sobre os Dados e a Análise

Todos os dados utilizados nesta análise são de domínio público, extraídos dos sistemas SIM e SINASC do Ministério da Saúde do Brasil, via MicroSUS.jl.

Esta análise foi realizada de forma independente, com propósitos puramente educacionais e de demonstração tecnológica. Os resultados, visualizações e conclusões aqui apresentados não representam um comunicado oficial de qualquer órgão governamental, seja municipal, estadual ou federal. O autor não possui vínculo nem recebeu financiamento de nenhuma entidade pública para a realização deste trabalho.

O objetivo deste artigo é estritamente didático: demonstrar o processo de coleta, classificação e visualização de dados de saúde pública utilizando a linguagem Julia. As informações não devem ser utilizadas como base para a tomada de decisões de políticas públicas, alocação de recursos ou avaliações de gestão. O autor não se responsabiliza por quaisquer interpretações, usos ou consequências derivadas da leitura deste material.


Escrito em 30/07/2026

Comentários

MorrisonKühlsen

Estatística descomplicada.

Artigos, fórmulas e tabelas
para estudantes e
profissionais.

morrisonkuhlsen.com