using TidierData
using DataFrames, CSV
using Statistics
using Test
ponds = CSV.read(joinpath("..", "data", "ponds.csv"), DataFrame)
nrow(ponds)180
Cedo ou tarde toda análise precisa de um cálculo que nenhum pacote oferece — um índice de um artigo, uma checagem de dados própria do seu protocolo de campo, um resumo que o seu laboratório sempre reporta. Este capítulo é sobre transformar esse cálculo em uma função fácil de chamar, difícil de usar errado, documentada e testada.
A Seção 4.3 apresentou a sintaxe. Aqui o foco é no que torna uma função boa: argumentos sensatos, erros claros, uma docstring e testes que avisam quando você a quebra.
Este capítulo e o Capítulo 11 são inspirados nos módulos de programação e de reamostragem do ecoR (Oliveira 2026), o wiki do curso de R do Instituto de Biociências da Universidade de São Paulo, produzido e mantido por Alexandre Adalardo de Oliveira. A ideia de construir índices ecológicos como funções, passo a passo, vem de lá. O texto, os exemplos e os exercícios daqui foram escritos do zero para Julia; os autores do ecoR não revisaram este livro.
Se quiser ir além, o material original está disponível gratuitamente (CC BY-SA 4.0):
using TidierData
using DataFrames, CSV
using Statistics
using Test
ponds = CSV.read(joinpath("..", "data", "ponds.csv"), DataFrame)
nrow(ponds)180
Como no Capítulo 4, a maioria das respostas aqui são funções que você escreve, e que checar chama com as próprias entradas de teste. Comece cada sessão com:
include("exercises/checar.jl")
using TidierData, DataFrames, CSV, Statistics, Test
ponds = CSV.read(joinpath("data", "ponds.csv"), DataFrame)Suponha que você queira saber quão variável é a riqueza entre as lagoas, em relação à média. O coeficiente de variação responde isso, e cabe em uma linha:
std(ponds.richness) / mean(ponds.richness) * 10068.12190985250986
No momento em que você o quer para uma segunda variável, ou por região, a linha vira uma função. Dê a ela um nome que diga o que calcula, e ao argumento um nome que diga o que ela espera:
cv(x) = std(x) / mean(x) * 100
cv(ponds.richness), cv(ponds.body_size)(68.12190985250986, 9.971208205402203)
A riqueza varia muito mais, em relação à média, do que o tamanho corporal.
Uma função pode retornar várias coisas ao mesmo tempo. A forma idiomática é uma tupla nomeada, cujos campos você lê pelo nome:
resumo_contagem(x) = (n = length(x), media = mean(x), var = var(x))
r = resumo_contagem(ponds.richness)
r.media, r.var(7.133333333333334, 23.613407821229057)
Para contagens, a razão variância/média diz como os indivíduos — ou as espécies — se distribuem entre as unidades amostrais. Se as contagens seguem uma Poisson, a variância é igual à média e a razão fica perto de 1; acima de 1 as contagens são mais agregadas, abaixo de 1 mais regulares. Escreva vmr(x) e verifique com checar("10.1", vmr).
Mesma forma que cv: uma linha, var sobre mean. var é a variância amostral, que divide por \(n - 1\).
vmr(x) = var(x) / mean(x)
vmr(ponds.richness)3.310290816060148
checar("10.1", vmr)✓ Correto!
A riqueza das 180 lagoas tem razão de cerca de 3,3: muito mais variável que uma Poisson com média única. Isso ainda não quer dizer que o processo de contagem seja estranho — as lagoas diferem em área, dossel e região. A Seção 10.5 volta a isso.
Uma função muitas vezes tem um padrão sensato que o usuário pode querer mudar. cv retorna uma porcentagem; alguém pode preferir uma proporção. Há duas formas de oferecer isso.
Um argumento posicional com valor padrão vem depois dos outros e é definido pela posição:
cv_pos(x, percentual = true) = std(x) / mean(x) * (percentual ? 100 : 1)
cv_pos(ponds.richness), cv_pos(ponds.richness, false)(68.12190985250986, 0.6812190985250985)
Um argumento nomeado (keyword) vem depois de um ponto e vírgula e é sempre definido pelo nome. O ponto e vírgula é obrigatório na definição da função; na chamada, uma vírgula também serve, como em sort(df, :coluna, rev = true) no Capítulo 5:
cv_kw(x; percentual = true) = std(x) / mean(x) * (percentual ? 100 : 1)
cv_kw(ponds.richness; percentual = false)0.6812190985250985
Para opções como esta, argumentos nomeados são a melhor escolha: cv_kw(x; percentual = false) diz o que faz, enquanto cv_pos(x, false) obriga o leitor a procurar o que false significa.
Uma função também pode recolher argumentos nomeados que não conhece e repassá-los com kwargs.... Aqui cv_arredondado trata digits e entrega todo o resto para cv_kw:
cv_arredondado(x; digits = 1, kwargs...) = round(cv_kw(x; kwargs...); digits)
cv_arredondado(ponds.richness), cv_arredondado(ponds.richness; percentual = false, digits = 3)(68.1, 0.681)
Em R
R tem um único tipo de argumento, que pode ser passado por posição ou por nome. Julia separa os dois: na definição, argumentos antes do ; são posicionais, os depois dele são nomeados e precisam do nome. kwargs... cumpre o papel do ... do R ao repassar opções para outra função.
Escreva standardize(x; center = true, scale = true). Por padrão ela retorna \((x - \bar x) / s\); center = false deixa de subtrair a média e scale = false deixa de dividir pelo desvio-padrão. Verifique com checar("10.2", standardize).
Monte o resultado em dois passos, cada um dependendo da sua opção: centrado = center ? x .- mean(x) : x, depois divida centrado por std(x) só se scale for verdadeiro.
function standardize(x; center = true, scale = true)
centrado = center ? x .- mean(x) : x
return scale ? centrado ./ std(x) : centrado
end
checar("10.2", standardize)✓ Correto!
std(x) é calculado sobre o x original, então é o mesmo com ou sem centralizar.
Uma função que retorna bobagem em silêncio é pior que uma que para. cv tem dois pontos cegos:
cv([0, 0, 0]), cv([-5, 5])(NaN, Inf)
NaN para um levantamento vazio, e um coeficiente infinito para dados com um valor negativo, que contagens não podem ter. Checagens no início da função pegam os dois casos, e throw(ArgumentError(...)) diz a quem chamou que o problema está na entrada, não na função:
function cv_checked(x)
any(<(0), x) && throw(ArgumentError("os valores devem ser não negativos"))
mean(x) > 0 || throw(ArgumentError("a média é zero; o CV não está definido"))
return std(x) / mean(x) * 100
end
try
cv_checked([-5, 5])
catch e
e
endArgumentError("os valores devem ser não negativos")
A forma condição && ação se lê “se a condição, então a ação”; condição || ação, “a menos que a condição, a ação”. As duas são idiomáticas para checagens como estas.
@assert é para os seus próprios erros
@assert condição para quando algo que nunca deveria acontecer acontece. Use-o para conferir o seu próprio raciocínio dentro de uma função; use ArgumentError para entradas que um usuário poderia plausivelmente errar.
vmr mais seguro
Reescreva vmr para que lance um ArgumentError quando receber menos de dois valores, algum valor negativo ou média zero, e continue retornando a razão nos outros casos. Verifique com checar("10.3", vmr).
Três checagens, uma por linha, antes do cálculo. As condições são length(x) < 2, any(<(0), x) e mean(x) == 0.
function vmr(x)
length(x) < 2 && throw(ArgumentError("são necessários pelo menos dois valores"))
any(<(0), x) && throw(ArgumentError("contagens não podem ser negativas"))
mean(x) == 0 && throw(ArgumentError("a média é zero; a razão não está definida"))
return var(x) / mean(x)
end
checar("10.3", vmr)✓ Correto!
Isso substitui o vmr de uma linha do 10.1: um método com os mesmos tipos de argumento sobrescreve o antigo.
Um texto colocado imediatamente antes de uma função vira a sua docstring, que é o que ?cv_documentado mostra no REPL e o que o VS Code mostra quando você passa o mouse sobre o nome. A convenção é a assinatura com recuo de quatro espaços, depois uma ou duas frases sobre o que a função faz e retorna:
"""
cv_documentado(x; percentual = true)
Coeficiente de variação de `x`: o desvio-padrão dividido pela média, vezes 100 a menos
que `percentual = false`. Lança um `ArgumentError` se a média for zero.
"""
function cv_documentado(x; percentual = true)
mean(x) > 0 || throw(ArgumentError("a média é zero; o CV não está definido"))
return std(x) / mean(x) * (percentual ? 100 : 1)
endMain.Notebook.cv_documentado
Em R
Docstrings cumprem o papel dos comentários do roxygen2, com uma diferença: fazem parte da linguagem e ficam disponíveis assim que a função é definida, sem etapa de compilação da documentação.
vmr
Adicione uma docstring ao seu vmr do 10.3: a assinatura, o que a razão significa e quais entradas ela recusa. Rode a definição de novo e experimente ?vmr no REPL. Depois verifique com checar("10.4", vmr).
A docstring vai logo acima de function vmr(x), sem nada entre as duas — nem mesmo uma linha em branco.
"""
vmr(x)
Razão variância/média das contagens `x`. Perto de 1 para contagens Poisson, acima de 1
quando as contagens são agregadas, abaixo de 1 quando são mais regulares. Lança um
`ArgumentError` para menos de dois valores, valores negativos ou média zero.
"""
function vmr(x)
length(x) < 2 && throw(ArgumentError("são necessários pelo menos dois valores"))
any(<(0), x) && throw(ArgumentError("contagens não podem ser negativas"))
mean(x) == 0 && throw(ArgumentError("a média é zero; a razão não está definida"))
return var(x) / mean(x)
end
checar("10.4", vmr)✓ Correto!
Uma função sua funciona dentro de um pipeline do Tidier — com um porém. O Tidier sabe que mean, std ou sum resumem uma coluna inteira, mas não sabe disso sobre o seu cv. Por padrão, ele aplica funções desconhecidas a cada valor separadamente, o que aqui calcularia um coeficiente de variação para cada lagoa:
errado = @chain ponds begin
@group_by(region)
@summarize(cv_richness = cv(richness))
end
nrow(errado), first(errado.cv_richness, 3)(180, [NaN, NaN, NaN])
180 linhas de NaN em vez de três números, e nenhum erro. Um ~ na frente da função diz ao Tidier para passar a coluna inteira:
@chain ponds begin
@group_by(region)
@summarize(cv_richness = ~cv(richness), cv_body = ~cv(body_size))
end| Row | region | cv_richness | cv_body |
|---|---|---|---|
| String15 | Float64 | Float64 | |
| 1 | Atlantic | 56.2043 | 8.88778 |
| 2 | Amazon | 53.2696 | 11.3731 |
| 3 | Cerrado | 60.8118 | 9.92402 |
O mesmo com o DataFrames.jl diretamente (Seção 5.7), onde a questão não aparece:
combine(groupby(ponds, :region), :richness => cv => :cv_richness)| Row | region | cv_richness |
|---|---|---|
| String15 | Float64 | |
| 1 | Atlantic | 56.2043 |
| 2 | Amazon | 53.2696 |
| 3 | Cerrado | 60.8118 |
Depois do @summarize, você deve ter uma linha por grupo. Se tiver uma linha por observação, o Tidier aplicou a sua função a cada valor: acrescente o ~.
Calcule a razão variância/média de richness para cada região, em uma coluna chamada vmr, usando a sua função. Verifique com checar("10.5", por_regiao). As razões ficam mais perto de 1 do que o valor de todas as lagoas juntas?
O bloco com ~cv desta seção, com ~vmr no lugar de ~cv. Sem o ~, o seu vmr do 10.3 recebe uma lagoa por vez e, com razão, a recusa: “são necessários pelo menos dois valores”.
por_regiao = @chain ponds begin
@group_by(region)
@summarize(vmr = ~vmr(richness))
end
checar("10.5", por_regiao)✓ Correto!
As razões, de cerca de 1,8 a 3,2, são todas menores que a das lagoas juntas (3,3): parte da variação vinha das diferenças entre regiões. Elas continuam acima de 1, porque lagoas da mesma região diferem em área e dossel. Na Seção 9.3, um modelo Poisson que levava esses preditores em conta não tinha mais sobredispersão. Uma razão acima de 1 em contagens brutas é motivo para procurar preditores, não prova de que as contagens não sejam Poisson.
Em Julia, funções são valores: podem ser guardadas em um vetor, passadas para outra função ou escritas sem nome. Isso facilita aplicar vários resumos de uma vez:
[f(ponds.richness) for f in (mean, median, cv, vmr)]4-element Vector{Float64}:
7.133333333333334
6.0
68.12190985250986
3.310290816060148
Uma função anônima se escreve x -> expressão. map aplica uma função a cada elemento de uma coleção — aqui, a três colunas dos dados:
map(col -> round(cv(col); digits = 1), eachcol(ponds[:, [:area_m2, :temp_c, :body_size]]))3-element Vector{Float64}:
117.6
11.6
10.0
A área varia enormemente entre lagoas; a temperatura, muito pouco. O coeficiente de variação põe variáveis com unidades diferentes na mesma escala.
O índice de dispersão de Morisita compara a frequência com que dois indivíduos sorteados caem na mesma unidade amostral com o que uma distribuição aleatória daria:
\[ I_d = n \, \frac{\sum_i x_i (x_i - 1)}{N (N - 1)} \]
onde \(x_i\) é a contagem na unidade \(i\), \(n\) o número de unidades e \(N = \sum_i x_i\). Ele fica perto de 1 para uma distribuição aleatória e acima de 1 para uma agregada. Escreva morisita(x), verifique com checar("10.6", morisita) e aplique-o a estas contagens de agrupamentos de girinos em dez quadrats ao longo da margem de uma lagoa:
quadrats = [0, 3, 0, 0, 7, 1, 0, 0, 5, 0]10-element Vector{Int64}:
0
3
0
0
7
1
0
0
5
0
x .* (x .- 1) dá \(x_i(x_i - 1)\) para todas as unidades de uma vez; sum soma tudo.
function morisita(x)
N = sum(x)
return length(x) * sum(x .* (x .- 1)) / (N * (N - 1))
end
morisita(quadrats)2.8333333333333335
checar("10.6", morisita)✓ Correto!
Cerca de 2,8: os agrupamentos estão fortemente agregados, em três dos dez quadrats. Uma versão para uso real recusaria N < 2, como no 10.3.
checar vem testando as suas funções com entradas de resposta conhecida. Você pode fazer o mesmo com as suas próprias funções usando a biblioteca padrão Test, e rodar os testes de novo toda vez que mudar o código:
@testset "cv_checked" begin
@test cv_checked([10, 10, 10]) == 0 # sem variação
@test cv_checked([1, 2, 3]) ≈ 50 # desvio 1, média 2
@test_throws ArgumentError cv_checked([0, 0])
@test_throws ArgumentError cv_checked([-1, 1])
end;Test Summary: | Pass Total Time cv_checked | 4 4 0.2s
Bons testes usam casos que você consegue calcular à mão e incluem as entradas que a função deve recusar. Digite ≈ como \approx seguido de Tab; ele compara números de ponto flutuante com uma tolerância, onde == falharia por arredondamento.
Quando uma função passa a ser útil além de um script, mova-a para um arquivo próprio — digamos funcoes.jl na pasta do projeto — e carregue-a com include("funcoes.jl") no início de cada análise. Os testes podem morar em um segundo arquivo ao lado. Quando a coleção crescer, o próximo passo é um pacote, que o Pkg cria para você com Pkg.generate.
@testset para o seu morisita com casos que você consegue calcular à mão: todos os indivíduos em uma unidade, e exatamente um indivíduo por unidade. O que cada um deve dar?vmr um método para Matrix (Seção 4.4) que retorne uma razão por coluna, tratando cada coluna como uma espécie contada ao longo dos quadrats (linhas).cv falha em uma coluna com valores faltantes, como canopy. Adicione um argumento nomeado skipmissing = false que, quando verdadeiro, os ignore. Qual padrão é mais seguro, e por quê?