Pular para o conteúdo

    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.

    avancado/cap27_copy_on_write.pylinhas 10 a 19
    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())
    
    Saída
    [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:

    avancado/cap27_copy_on_write.pylinhas 24 a 26
    apelido = df
    apelido.loc[0, "a"] = 5
    print(df["a"].tolist(), apelido is df)
    
    Saída
    [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:

    avancado/cap27_copy_on_write.pylinhas 31 a 34
    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())
    
    Saída
    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:

    avancado/cap27_copy_on_write.pylinhas 39 a 46
    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())
    
    Saída
    [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:

    avancado/cap27_copy_on_write.pylinhas 51 a 57
    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"])
    
    Saída
    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:

    avancado/cap27_copy_on_write.pylinhas 62 a 77
    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())
    
    Saída
    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çãoO que acontece no Pandas 3
    s = df["a"] e depois s[...] = xAltera só s; o df não muda
    b = df e depois b.loc[...] = xAltera o df: é o mesmo objeto
    df[mascara]["col"] = xNão altera o df, e avisa (ChainedAssignmentError)
    df.loc[mascara, "col"] = xAltera 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.