Ir para o conteúdo

Data Quality

Qualidade dos Dados

Qualidade de dados não é arrumação. Um conjunto de dados pode estar perfeitamente formatado, completamente preenchido e inteiramente errado; outro pode estar cheio de buracos e ainda assim responder à pergunta que você está fazendo. Qualidade é adequação a uma decisão — e só é mensurável contra a decisão que o modelo deve tomar.

Esse é o enquadramento útil, porque transforma uma virtude vaga em três perguntas verificáveis.

As três perguntas

  1. Está lá? — valores faltantes e se as linhas que sumiram são justamente as de que você precisava.
  2. Está certo? — outliers, erros de digitação, valores sentinela e rótulos que dizem a coisa errada.
  3. É o dado que o modelo vai de fato encontrar? — o conjunto de treino é uma amostra de uma população e o modelo será implantado sobre outro sorteio dela.

Tudo nesta página é uma dessas três dando errado. A última tem capítulo próprio — veja divisão treino/validação/teste e vazamento de dados — então aqui ficamos com as duas primeiras e com a única coisa que ninguém confere: os rótulos.


1. Valores faltantes

Todo curso ensina o mesmo reflexo: conte os NaN, preencha com a média, siga em frente. O reflexo está errado e a razão é que nem todo dado faltante falta pelo mesmo motivo. O que importa não é quanto sumiu — é quem sumiu.

Mecanismo Quem desaparece Exemplo O que custa
MCAR — faltante completamente ao acaso ninguém em particular um bug de log descarta linhas aleatoriamente só tamanho de amostra
MAR — faltante ao acaso um grupo que você consegue identificar por outras colunas os jovens pulam a pergunta de renda e a idade está registrada nada, se você usar as outras colunas
MNAR — faltante não ao acaso um grupo definido pelo próprio valor que falta quem ganha muito não declara a renda um viés que você não consegue medir nem remover só com os dados

Os nomes atrapalham — "ao acaso" significa duas coisas diferentes — então leia os três como uma pergunta só: consigo dizer, pelo que ainda tenho, quem foi embora?

  • MCAR: ninguém em particular foi embora, então a amostra que sobra continua justa.
  • MAR: alguém foi embora, mas outra coluna diz quem, então a lacuna pode ser reconstruída.
  • MNAR: alguém foi embora e a única coisa que identificava essa pessoa é justamente o que falta. Nenhuma esperteza recupera isso. É esse o caso que acaba com carreiras e ele é invisível nos dados: MCAR e MNAR são idênticos num df.isnull().sum().

O painel derruba 30% de uma coluna de renda de três maneiras diferentes e aplica três consertos padrão. O contorno azul é a população; as barras laranja são o que você acabaria analisando.

quem some 
o conserto 

O que a imputação faz de verdade

Imputar não recupera informação. Inventa um valor plausível e então — esta é a parte que importa — apaga a evidência de que ele foi inventado. Duas consequências seguem daí e as duas aparecem no painel:

  1. Não conserta viés algum. A imputação pela média preenche cada buraco com a média do que sobrou, então a média da coluna consertada é exatamente a média das linhas observadas. Se descartar era enviesado, preencher é enviesado na mesma medida, só que agora com cara de conjunto completo.
  2. Encolhe a dispersão. Toda linha imputada fica exatamente no centro, então a variância cai, as correlações enfraquecem e todo intervalo de confiança calculado depois fica estreito demais. Você fica mais confiante justamente porque sabe menos.
from sklearn.impute import SimpleImputer, KNNImputer
from sklearn.pipeline import Pipeline

# o imputer aprende uma estatística, logo é um passo de fit: vai dentro do Pipeline
pipe = Pipeline([
    ('impute', SimpleImputer(strategy='median')),   # mediana: robusta aos outliers abaixo
    ('model',  MLPClassifier()),
])

O imputer é um vazamento esperando para acontecer

O SimpleImputer aprende uma mediana, o KNNImputer aprende uma vizinhança — os dois são passos de fit e ajustar qualquer um deles no dataset inteiro antes da divisão leva informação do teste para o treino. Mantenha-os dentro de um Pipeline. Veja vazamento de dados.

A ausência é, ela mesma, uma feature

Se um valor é MNAR, o fato de estar faltando carrega a informação que o valor carregaria. Jogar isso fora é uma segunda perda por cima da primeira:

# guarde o fato, não só o valor preenchido
X['renda_faltante'] = X['renda'].isna().astype(int)
X['renda'] = X['renda'].fillna(X['renda'].median())

O SimpleImputer(add_indicator=True) faz isso por você. Custa quase nada e, sob MNAR, costuma ser a coluna mais preditiva da tabela — o que já diz alguma coisa sobre o quanto a imputação jogou fora.

Os valores faltantes que não parecem faltantes

O df.isnull().sum() encontra NaN. Ele não encontra:

O que você vê O que significa
-999, -1, 9999 uma sentinela que um sistema legado escreve no lugar de NULL
0 às vezes um zero real, às vezes "não medido"
"", " ", "N/A", "desconhecido", "-" nulos em texto livre, cada um uma categoria distinta para um one-hot
1900-01-01, 1970-01-01 a época, fazendo as vezes de "sem data"

Esses são piores que NaN, porque toda ferramenta os trata como dado. Uma sentinela -999 numa coluna de altura não só acrescenta um outlier — ela arrasta a média, infla o desvio padrão e, como o laboratório abaixo mostra, quebra justamente o teste que você usaria para detectá-la.


2. Outliers

Um outlier é um ponto distante dos demais. Isso é uma afirmação sobre a distribuição e não é motivo para apagar nada. A pergunta é sempre qual dos dois casos é:

  • Um extremo genuíno. Um salário de dez milhões num conjunto de rendas. Real, raro e muitas vezes a linha mais importante da tabela — em detecção de fraude ou falha de equipamento, os outliers são a tarefa.
  • Um erro. Uma altura de 1750 cm porque a vírgula escorregou. Não há nada a aprender aqui; a linha é um erro de digitação com um número junto.

Nenhum teste estatístico separa os dois, porque a diferença é sobre o mundo, não sobre os números. Um teste só consegue dizer isto é incomum; decidir o que significa é trabalho de domínio.

Detecção e como ela falha

# z-score: distância da média, em desvios padrão
z = ((df - df.mean()) / df.std()).abs()
outliers = (z > 3).any(axis=1)

# IQR: distância dos quartis — robusto, porque quartis não se mexem
q1, q3 = df.quantile(0.25), df.quantile(0.75)
iqr = q3 - q1
outliers = ((df < q1 - 1.5 * iqr) | (df > q3 + 1.5 * iqr)).any(axis=1)

# z-score modificado: distância da mediana, em MADs — o mais robusto dos três
median = df.median()
mad = (df - median).abs().median()
outliers = (0.6745 * (df - median).abs() / mad > 3.5).any(axis=1)

O primeiro tem um defeito que importa. Ele mede cada ponto contra a média e o desvio padrão — os dois já corrompidos pelos próprios outliers. Um valor ruim infla o desvio padrão; dez inflam o suficiente para que nenhum deles pareça mais incomum. Eles se escondem atrás do estrago que causaram. O nome disso é mascaramento e o passo 2 do laboratório mostra um teste z-score saindo de encontrar todos os 50 valores plantados para encontrar zero de 100 conforme a contaminação cresce.

A mediana e o MAD não se mexem quando uma minoria dos valores se mexe. É essa a razão inteira para preferi-los.

Tratamento

Estratégia Quando é a escolha certa
Corrigir o erro é recuperável — uma unidade, uma vírgula, uma sentinela conhecida
Remover um erro claro que você não consegue corrigir, em linhas poucas o bastante para não fazer falta
Limitar / winsorizar o valor é real mas a cauda desestabiliza o treino; corte num percentil
Transformar (log, sqrt) a variável é assimétrica por natureza — renda, contagens, durações
Manter os extremos são o fenômeno que você está modelando
Sinalizar você não consegue decidir: acrescente uma coluna eh_extremo e deixe o modelo decidir

Para uma rede neural em particular, o argumento a favor de limitar ou transformar não é pureza estatística — é que um único valor de entrada enorme produz um gradiente enorme e uma linha dessas pode desfazer uma época inteira de aprendizado.


3. Duplicatas

Duplicatas exatas inflam o que duplicam: uma linha que aparece três vezes conta três vezes na perda e puxa a fronteira de decisão para si.

print(df.duplicated().sum())
df = df.drop_duplicates()

Quase-duplicatas são o caso mais difícil e o mais danoso — duas fotografias com um segundo de diferença, o mesmo artigo raspado de dois espelhos, um registro reinserido com outro id. Elas sobrevivem ao drop_duplicates() porque nada é idêntico byte a byte.

O custo real não é a contagem

Uma linha duplicada que cai dos dois lados da divisão deixa de ser redundância e vira gabarito: o modelo é pontuado numa linha que memorizou no treino. Remova duplicatas antes de dividir e, quando não der, divida por grupo. É a mesma falha que o capítulo de vazamento mede em 0,986 contra os honestos 0,730.


4. Ruído nos rótulos — o que ninguém audita

Tudo acima é sobre as colunas. A coluna que ninguém audita é justamente a que o modelo tenta prever.

Times gastam semanas em features e aceitam y como dado. Mas y foi produzido por alguém — um anotador atravessando dez mil imagens, uma regra escrita em SQL três anos atrás, um código clínico digitado sob pressão de tempo — e está errado com mais frequência do que se prevê. Um estudo de dez dos conjuntos de teste de referência mais usados encontrou erros de rótulo em média de 3,4% e cerca de 6% do conjunto de validação do ImageNet.1 São esses os conjuntos cujos números a área inteira cita com três casas decimais.

Para uma rede neural isso não é um problema pequeno, por um fato específico e bem estabelecido: uma rede com capacidade suficiente ajusta quaisquer rótulos, inclusive rótulos atribuídos inteiramente ao acaso.2 Ela não resiste aos seus erros. Ela os memoriza, reporta uma curva de treinamento linda e perde exatamente tanta acurácia de generalização quanto você lhe deu de rótulo errado.

O painel torna isso concreto. A linha tracejada é a regra que os rótulos deveriam codificar; os pontos com anel vermelho receberam a resposta errada. Ponha k = 1 e veja o modelo escavar uma ilha em volta de cada um deles:

Duas coisas a tirar dali e as duas generalizam muito além do k-vizinhos:

  • Uma pontuação de treino perfeita não é evidência de nada. Com k = 1 a acurácia de treino é 1,000 quer os rótulos estejam 0% ou 40% errados. O número que se mexe é o medido contra a verdade.
  • Ajustar pior o conjunto de treino pode ser a resposta certa. Aumentar k faz o modelo discordar abertamente de dezenas dos rótulos que recebeu — e a pontuação honesta sobe.

O que fazer a respeito

Em ordem de quanto bem fazem:

  1. Olhe os seus rótulos. Sorteie 100 linhas e rotule você mesmo. Nada nesta lista substitui conhecer a sua taxa de erro.
  2. Meça a concordância. Dois anotadores no mesmo subconjunto, depois o \(\kappa\) de Cohen. Se humanos não concordam entre si, modelo nenhum vai concordar com eles.
  3. Deixe o modelo achá-los. Linhas com perda persistentemente alta, ou que o modelo contradiz com confiança, são as candidatas mais prováveis a erro de rótulo. Formalizada, essa ideia é o confident learning, o método por trás da auditoria citada acima.1
  4. Pare cedo. Redes aprendem o sinal antes de memorizar o ruído, então o estrago se acumula nas épocas finais. No passo 3 do laboratório, parar cedo transforma 0,639 de volta em 0,825 com 40% de ruído.
  5. Suavize os alvos. O label smoothing troca um 1 duro por \(1-\varepsilon\), o que limita o quanto a rede pode ficar confiante sobre qualquer exemplo — inclusive sobre os errados.
  6. Faça média sobre isso. Um ensemble discorda de si mesmo exatamente onde os rótulos não são confiáveis.

A auditoria

Rode isto antes de qualquer outra coisa, em todo conjunto de dados novo. E leia o resultado inteiro:

def audit(df, target=None):
    print(f"shape             {df.shape}")
    print(f"exact duplicates  {df.duplicated().sum()}")

    print("\nmissing (%)")
    missing = (df.isna().mean() * 100).round(1)
    missing = missing[missing > 0].sort_values(ascending=False)
    print(missing.to_string() if len(missing) else "  none")

    print("\nsentinels posing as data")
    for col in df.select_dtypes('number'):
        if df[col].nunique() <= 2:                  # um flag ou um alvo binário, não uma medida
            continue
        for sentinel in (-1, 0, -999, 9999):
            n = (df[col] == sentinel).sum()
            if n and n / len(df) > 0.01:
                print(f"  {col}: {sentinel} appears {n} times ({n/len(df):.1%})")

    print("\nconstant or near-constant columns")
    for col in df:
        top = df[col].value_counts(normalize=True, dropna=False).iloc[0]
        if top > 0.98:
            print(f"  {col}: one value covers {top:.1%} of rows")

    print("\ntails — a min or a max far outside the 1%/99% range is a suspect")
    print(df.describe(percentiles=[.01, .5, .99]).T[['1%', '50%', '99%', 'min', 'max']])

    if target:
        print(f"\nclass balance\n{df[target].value_counts(normalize=True)}")

O checklist

  • Que fração de cada coluna está faltando e por quê — MCAR, MAR ou MNAR?
  • Há valores sentinela (-999, 0, "N/A") se passando por dado?
  • O próprio padrão de ausência prediz o alvo?
  • Os valores extremos são genuínos ou são erros de digitação e de unidade?
  • O teste de outlier era ele mesmo robusto — mediana e MAD, não média e desvio padrão?
  • Há duplicatas ou quase-duplicatas e algum par poderia atravessar a divisão?
  • Alguém leu uma amostra dos rótulos?
  • Dois anotadores concordam? Em que medida?
  • Todo passo de limpeza que aprende uma estatística está dentro do Pipeline?

Laboratório: três perguntas, medidas

Três experimentos curtos, reproduzíveis com numpy e scikit-learn.

Passo 1 — o mecanismo decide, não o percentual

Os mesmos 30% de uma coluna de renda somem de três maneiras diferentes. A renda depende da idade; a idade é sempre observada. bias_drop é o que você obtém descartando as linhas incompletas, bias_model_impute prevendo a renda que falta a partir da idade observada e std_ratio é o que a imputação pela média faz com a dispersão.

mechanism missing bias_drop bias_model_impute std_ratio
MCAR 30% +0.05 -0.04 0.86
MAR 31% +3.18 -0.14 0.90
MNAR 30% -8.41 -5.69 0.30
truth 0% mean = 24.14 — std = 16.83
"""The mechanism decides whether a missing value costs you anything.

Income depends on age, and age is always observed. Thirty percent of the income
column is then hidden three different ways, and the same three repairs are
tried on each. What changes is not how much is missing — it is always 30% —
but whether the rows that vanished are the ones you needed.

Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""

import numpy as np
from sklearn.linear_model import LinearRegression

N, FRACTION = 4000, 0.30

rng = np.random.default_rng(7)
age = rng.normal(size=N)                                    # always observed
income = np.exp(3.0 + 0.5 * age + 0.4 * rng.normal(size=N))  # and it depends on age
TRUE_MEAN, TRUE_STD = income.mean(), income.std()


def hide(mechanism):
    r = np.random.default_rng(0)
    if mechanism == "MCAR":                 # the server dropped rows, blindly
        return r.random(N) < FRACTION
    if mechanism == "MAR":                  # the young skip the question — and age is observed
        p = 1 / (1 + np.exp(2.2 * age))
        return r.random(N) < p * FRACTION / p.mean()
    return income >= np.quantile(income, 1 - FRACTION)      # MNAR: the rich do not answer


rows = []
for mechanism in ("MCAR", "MAR", "MNAR"):
    gone = hide(mechanism)
    seen = income[~gone]

    # 1. drop the rows — which is also exactly what mean imputation estimates
    drop = seen.mean()

    # 2. mean imputation: same centre, and a standard deviation that is now a lie
    filled = np.where(gone, drop, income)

    # 3. regression imputation: predict the missing income from the observed age
    fit = LinearRegression().fit(age[~gone, None], np.log(seen))
    residual = np.log(seen) - fit.predict(age[~gone, None])
    model = np.where(
        gone, np.exp(fit.predict(age[:, None]) + residual.var() / 2), income
    ).mean()

    rows.append((mechanism, gone.mean(), drop - TRUE_MEAN,
                 model - TRUE_MEAN, filled.std() / TRUE_STD))

print(f"| `mechanism` | `missing` | `bias_drop` | `bias_model_impute` | `std_ratio` |")
print("|---|---:|---:|---:|---:|")
for name, frac, bias_drop, bias_model, ratio in rows:
    print(f"| `{name}` | {frac:.0%} | **{bias_drop:+.2f}** | **{bias_model:+.2f}** "
          f"| {ratio:.2f} |")
print(f"| `truth` | 0% | mean = {TRUE_MEAN:.2f} | — | std = {TRUE_STD:.2f} |")

Leia as linhas umas contra as outras. Sob MCAR, descartar erra por +0,05 numa média de 24,14 — nada. Sob MAR, descartar erra por +3,18 (um erro de 13%) e o conserto por modelo traz de volta para −0,14: o viés era corrigível porque a idade dizia quem tinha saído. Sob MNAR, descartar erra por −8,41 — a estimativa fica 35% baixa demais — e o conserto só chega a −5,69, porque nenhuma coluna da tabela sabe o que os ricos teriam respondido.

A coluna std_ratio é a falha mais silenciosa. Imputar pela média 30% de uma coluna deixa a dispersão em 0,86 da verdadeira mesmo sob MCAR, onde nada estava enviesado. Sob MNAR ela cai para 0,30: o terço superior da distribuição foi substituído por um único valor e todo erro padrão calculado depois vira ficção.

Passo 2 — o teste de outlier que para de funcionar

Uma coluna de altura em centímetros, com um sistema legado escrevendo -999 no lugar de NULL.

sentinels mean std planted z > 3 mad_z > 3.5
0% 170.2 10.0 0 1 1
1% 158.5 116.8 10 10 11
5% 111.6 255.0 50 50 50
10% 53.3 350.9 100 0 100
15% -5.2 417.6 150 0 150
"""Why the z-score stops finding outliers exactly when there are enough to matter.

A height column, in centimetres, where some rows carry the sentinel -999 that a
legacy system writes instead of NULL. The z-score test measures each value
against the mean and the standard deviation — both of which the sentinels
themselves have already wrecked. Past a certain contamination the sentinels
have inflated the standard deviation so much that they no longer look extreme:
they hide behind the damage they caused. This is called masking.

The median and the MAD do not move when a minority of the values does, so the
modified z-score keeps working.

Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""

import numpy as np

N, MEAN, STD, SENTINEL = 1000, 170.0, 10.0, -999.0

rng = np.random.default_rng(11)
clean = rng.normal(MEAN, STD, N)

print("| `sentinels` | `mean` | `std` | `planted` | `z > 3` | `mad_z > 3.5` |")
print("|---:|---:|---:|---:|---:|---:|")
for fraction in (0.00, 0.01, 0.05, 0.10, 0.15):
    height = clean.copy()
    hidden = rng.choice(N, int(fraction * N), replace=False)
    height[hidden] = SENTINEL
    k = len(hidden)

    # the textbook test: distance from the mean, in standard deviations
    z = np.abs(height - height.mean()) / height.std()

    # the robust twin: distance from the median, in MADs
    median = np.median(height)
    mad = np.median(np.abs(height - median))
    modified_z = 0.6745 * np.abs(height - median) / mad

    print(f"| {fraction:.0%} | {height.mean():.1f} | {height.std():.1f} | {k} "
          f"| **{int((z > 3).sum())}** | **{int((modified_z > 3.5).sum())}** |")

Acompanhe primeiro a coluna mean: 170,2 cm, depois 158,5, depois 111,6, depois 53,3, depois −5,2. Essa é a altura média reportada de uma população e nenhuma exceção foi levantada em momento algum.

Agora os dois testes. Com 1% e 5% de contaminação, os dois encontram todas as sentinelas. Com 10%, o z-score encontra 0 de 100 — as sentinelas empurraram o desvio padrão para 350,9 e, contra uma dispersão desse tamanho, nada fica a três desvios de coisa alguma. O z-score modificado encontra as 100 e as 150 com 15%, porque a mediana e o MAD nunca se mexeram.

Experimente

Troque SENTINEL de -999 para 230 — uma altura de aparência plausível em vez de uma óbvia. O z-score ainda desaba para 0 com 10% e agora a coluna mean fica numa faixa de aparência inteiramente razoável. Nada, absolutamente nada, avisaria você.

Passo 3 — a rede ajusta o que quer que você mande

As entradas ficam intactas. Só uma fração dos rótulos de treino é invertida.

flipped_labels train_acc test_acc test_acc_early_stop
0% 1.000 0.951 0.946
10% 1.000 0.886 0.940
20% 1.000 0.802 0.909
30% 1.000 0.709 0.873
40% 1.000 0.639 0.825
"""A network fits wrong labels as happily as right ones.

The task is linearly separable up to a little noise, so a clean network reaches
about 0.95 on held-out data. Then a fraction of the *training* labels is
flipped — the inputs are untouched, only the answers are corrupted — and the
same network is trained again.

The training accuracy stays at 1.000 no matter how much of the training set is
wrong. That is the whole point: a network with enough capacity memorizes the
errors, reports a perfect fit and tells you nothing. Only the held-out score
knows. The last column shows early stopping getting some of it back, because
memorizing noise takes longer than learning the signal.

Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""

import numpy as np
from sklearn.neural_network import MLPClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

N, DIM, SPLIT = 2000, 12, 1200

rng = np.random.default_rng(23)
X = rng.normal(size=(N, DIM))
direction = rng.normal(size=DIM)
y = ((X @ direction + 0.35 * rng.normal(size=N)) > 0).astype(int)
train, test = slice(0, SPLIT), slice(SPLIT, N)

net = lambda **kw: make_pipeline(
    StandardScaler(), MLPClassifier((256, 128), max_iter=4000, random_state=0, **kw)
)

print("| `flipped_labels` | `train_acc` | `test_acc` | `test_acc_early_stop` |")
print("|---:|---:|---:|---:|")
for rate in (0.00, 0.10, 0.20, 0.30, 0.40):
    noisy = y[train].copy()
    flip = np.random.default_rng(0).random(SPLIT) < rate
    noisy[flip] ^= 1                                   # only the answers are corrupted

    memorizer = net().fit(X[train], noisy)
    stopped = net(early_stopping=True, n_iter_no_change=10).fit(X[train], noisy)

    print(f"| {rate:.0%} | **{memorizer.score(X[train], noisy):.3f}** "
          f"| **{memorizer.score(X[test], y[test]):.3f}** "
          f"| {stopped.score(X[test], y[test]):.3f} |")

A coluna train_acc é 1,000 em todas as linhas. Com 40% dos rótulos de treino errados, a rede ajusta todos eles, perfeitamente. A curva de perda não dá sinal nenhum de que há algo estranho. Enquanto isso a pontuação honesta cai de 0,951 para 0,639 — quase ponto a ponto com o ruído que lhe foi dado.

A última coluna é o consolo. Parar cedo recupera 0,825 daquele 0,639, porque o sinal é aprendido nas épocas iniciais e a memorização vem depois: parar enquanto a pontuação de validação ainda melhora é parar antes de a rede chegar aos rótulos errados. É mitigação, não conserto — 0,825 ainda está bem abaixo dos 0,951 que rótulos limpos teriam comprado.

O que o laboratório ensina

Cada passo quebra um hábito. Passo 1: "30% faltando" não é uma medida — é preciso saber quem saiu. Passo 2: o teste de outlier padrão falha justamente quando há outliers suficientes para importar. Passo 3: a curva de treinamento não é evidência e os rótulos merecem a auditoria que você vem dando às features.



  1. Northcutt, C. G., Athalye, A., Mueller, J. Pervasive Label Errors in Test Sets Destabilize Machine Learning Benchmarks. NeurIPS Datasets and Benchmarks, 2021. Ver também Northcutt, C. G., Jiang, L., Chuang, I. Confident Learning: Estimating Uncertainty in Dataset Labels, JAIR, 2021. ↩↩

  2. Zhang, C., Bengio, S., Hardt, M., Recht, B., Vinyals, O. Understanding Deep Learning Requires Rethinking Generalization. ICLR 2017 — uma rede que atinge o estado da arte no CIFAR-10 também atinge erro de treino zero nas mesmas imagens com os rótulos embaralhados ao acaso. ↩