Capítulo 27, Avançado
Cópia sob escrita
"Alterei aqui e mudou ali" é a classe de bug mais antiga do Pandas. O Pandas 3 a elimina com uma regra única, a cópia sob escrita, e entender essa regra explica os avisos que você vai ver e os padrões antigos que deixaram de funcionar.
A regra
Desde o Pandas 3, todo objeto derivado de outro se comporta como uma cópia independente. Uma seleção, uma coluna, um resultado de método: alterar qualquer um deles nunca altera o original. Por baixo, o Pandas não copia os dados na hora (seria caro): ele os compartilha e só copia no momento em que alguém escreve. A regra visível é simples, e o desempenho continua bom.
import warnings
import numpy as np
import pandas as pd
df = pd.DataFrame({"a": [1, 2, 3], "b": [10.0, 20.0, 30.0]})
serie = df["a"]
serie.iloc[0] = 99
print(df["a"].tolist(), serie.tolist())
[1, 2, 3] [99, 2, 3]
A coluna foi extraída, e o 99 foi escrito só nela: o df ficou como estava. Antes do Pandas 3, essa escrita podia mudar a tabela original, ou não, dependendo de detalhes internos que ninguém conseguia prever.
O que a regra não muda: dois nomes, um objeto
A cópia sob escrita é sobre objetos derivados. Atribuir uma tabela a outro nome não cria nada: são dois nomes para o mesmo objeto (o modelo de etiquetas do Python), e escrever por um deles aparece no outro:
apelido = df
apelido.loc[0, "a"] = 5
print(df["a"].tolist(), apelido is df)
[5, 2, 3] True
Compartilhar até escrever
Uma cópia "rasa" (deep=False) começa compartilhando a memória com o original, e a primeira escrita a separa. Dá para ver isso acontecendo:
raso = df.copy(deep=False)
print(np.shares_memory(df["a"].to_numpy(), raso["a"].to_numpy()))
raso.loc[1, "a"] = 77
print(np.shares_memory(df["a"].to_numpy(), raso["a"].to_numpy()), df["a"].tolist())
True
False [5, 2, 3]
Antes de escrever, os dois compartilham a memória (True), e nenhuma cópia foi feita. Depois da escrita em raso, a memória se separou (False), e o df continua com os valores dele. É por isso que o .copy() defensivo, que se escrevia por reflexo, deixou de ser necessário na maioria dos casos.
A atribuição encadeada deixou de funcionar
O padrão df[condição]["coluna"] = valor altera um objeto intermediário (uma cópia temporária), e nunca o df. No Pandas 3, isso gera um ChainedAssignmentError, e a tabela fica intacta. O jeito certo é uma única operação .loc:
tabela = pd.DataFrame({"a": [1, 2, 3], "b": [10.0, 20.0, 30.0]})
with warnings.catch_warnings(record=True) as avisos:
warnings.simplefilter("always")
tabela[tabela["a"] > 1]["b"] = 0
print(tabela["b"].tolist(), [a.category.__name__ for a in avisos])
tabela.loc[tabela["a"] > 1, "b"] = 0
print(tabela["b"].tolist())
[10.0, 20.0, 30.0] ['ChainedAssignmentError']
[10.0, 0.0, 0.0]
O primeiro comando não mudou nada, e o aviso diz por quê. O segundo, com o .loc[linhas, coluna], escreveu onde devia.
`to_numpy()` devolve uma visão protegida
Para a escrita não vazar por um array do NumPy, o to_numpy() devolve, quando pode, uma visão somente leitura. Quer um array que você possa alterar? Peça uma cópia:
leitura = df["b"].to_numpy()
print(leitura.flags["WRITEABLE"])
try:
leitura[0] = 1
except ValueError as erro:
print("ValueError:", erro)
print(df["b"].to_numpy(copy=True).flags["WRITEABLE"])
False
ValueError: assignment destination is read-only
True
O que a regra não resolve: funções que alteram o argumento
Uma função que escreve na tabela que recebeu altera a tabela do chamador, porque os dois nomes são o mesmo objeto. A cópia sob escrita não impede isso:
def normalizar_mutando(tabela):
tabela.loc[:, "b"] = tabela["b"] / tabela["b"].max()
return tabela
def normalizar_sem_mutar(tabela):
return tabela.assign(b=tabela["b"] / tabela["b"].max())
original = df.copy()
resultado = normalizar_mutando(df)
print(resultado is df, df["b"].tolist() != original["b"].tolist())
df = original.copy()
novo = normalizar_sem_mutar(df)
print(novo is df, df["b"].tolist() == original["b"].tolist())
True True
False True
A primeira função alterou o df do chamador (True, e os valores mudaram). A segunda devolveu uma tabela nova e deixou o df intacto. A regra que eu sigo para funções de análise: recebem uma tabela e devolvem outra, com assign, rename e companhia, e não escrevem no que receberam.
| Situação | O que acontece no Pandas 3 |
|---|---|
s = df["a"] e depois s[...] = x | Altera só s; o df não muda |
b = df e depois b.loc[...] = x | Altera o df: é o mesmo objeto |
df[mascara]["col"] = x | Não altera o df, e avisa (ChainedAssignmentError) |
df.loc[mascara, "col"] = x | Altera o df, como esperado |
df.copy(deep=False) | Compartilha a memória até a primeira escrita |
df["col"].to_numpy() | Visão somente leitura (use copy=True para escrever) |
Exercício 1
Uma função que não muda o que recebe
Escreva acrescentar_total(tabela), que devolva uma tabela nova com a coluna total (soma de a e b), e confira que a tabela original não ganhou a coluna.