Saltar al contenido

Indice / pandas: fundamentos / 4.7

Crear y modificar columnas

Agregar una columna derivada es una línea. Modificar unas celdas concretas también… salvo que lo escribas de la forma que parece natural, que en pandas 3 no modifica nada y solo emite una advertencia.

Para qué sirve

Casi ningún dato interesante viene en el archivo: el margen, el valor del stock, la bandera de «este caso hay que revisarlo». Todo eso se calcula, y se calcula sobre la columna entera de una vez, no fila por fila.

Y acá aparece el error más famoso de pandas, que en la versión 3 cambió de forma: la asignación encadenada. Ya no rompe nada… porque directamente no hace nada.

ELI5Explicado facil

Sacar una fotocopia de la página 5 de un libro, corregirla con birome, y después sorprenderte de que el libro siga con el error.

Eso es la asignación encadenada: el primer corchete te dio una fotocopia, y el segundo escribió sobre la fotocopia. pandas te avisa por lo bajo - una advertencia que en un cuaderno de Jupyter se pierde entre las demás salidas - y sigue como si nada.

El ejemplo mínimo

EjemploDos formas de agregar una columna, y en que se diferencian
import pandas as pd

inventario = pd.read_csv("inventario_productos.csv")

# 1. Asignacion directa: modifica el DataFrame que tenes.
directo = inventario.copy()
directo["valor"] = (directo["cantidad"] * directo["precio_unitario"]).round(2)
print("asignacion directa -> columnas:", list(directo.columns))

# 2. assign: devuelve una tabla NUEVA con la columna agregada.
con_assign = inventario.assign(
    valor=(inventario["cantidad"] * inventario["precio_unitario"]).round(2))
print("assign             -> columnas:", list(con_assign.columns))

print()
print("y el original sigue sin la columna:", list(inventario.columns))

print()
print(con_assign[["codigo", "cantidad", "precio_unitario", "valor"]]
      .head(3).to_string(index=False))
asignacion directa -> columnas: ['codigo', 'producto', 'categoria', 'cantidad', 'precio_unitario', 'valor']
assign             -> columnas: ['codigo', 'producto', 'categoria', 'cantidad', 'precio_unitario', 'valor']

y el original sigue sin la columna: ['codigo', 'producto', 'categoria', 'cantidad', 'precio_unitario']

codigo  cantidad  precio_unitario    valor
  P001     112.0             4.53   507.36
  P002      82.0           781.78 64105.96
  P003     108.0           872.23 94200.84
Cuál de las dos usar

df["nueva"] = ... modifica el DataFrame que tenés y es lo más directo cuando estás explorando. assign devuelve una tabla nueva y es lo que hace falta para encadenar y para escribir funciones puras - la lección 5.7 entera se apoya en eso.

Encadenar

Ejemploassign encadenado, y como usar una columna recien creada
import pandas as pd

inventario = pd.read_csv("inventario_productos.csv")

resultado = (inventario
             .assign(valor=lambda d: (d["cantidad"] * d["precio_unitario"]).round(2))
             .assign(caro=lambda d: (d["valor"] > 50000).astype("int64")))

print(resultado[["codigo", "valor", "caro"]].head(5).to_string(index=False))
print()
print("productos caros:", int(resultado["caro"].sum()))

print()
# Con lambda, d es la tabla TAL COMO VA LLEGANDO a ese assign. Por eso el
# segundo puede usar 'valor', que no existia antes de la linea anterior.
print("En un solo assign tambien se puede, siempre con lambda:")
en_uno = inventario.assign(
    valor=lambda d: (d["cantidad"] * d["precio_unitario"]).round(2),
    caro=lambda d: (d["valor"] > 50000).astype("int64"),
)
print("columnas:", list(en_uno.columns))
print("mismo resultado:", bool(en_uno["caro"].equals(resultado["caro"])))
codigo    valor  caro
  P001   507.36     0
  P002 64105.96     1
  P003 94200.84     1
  P004      NaN     0
  P005 18548.92     0

productos caros: 12

En un solo assign tambien se puede, siempre con lambda:
columnas: ['codigo', 'producto', 'categoria', 'cantidad', 'precio_unitario', 'valor', 'caro']
mismo resultado: True

El truco del lambda es lo que hace útil a assign: dentro de la función, d es la tabla tal como llega a ese punto. Por eso el segundo cálculo puede usar la columna que creó el primero, aunque no existiera antes de esa línea.

Dónde se rompe

EjemploEl error que no levanta excepcion: la asignacion encadenada
import warnings

import pandas as pd

inventario = pd.read_csv("inventario_productos.csv")
print("P007 antes:", inventario.loc[inventario["codigo"] == "P007",
                                    "cantidad"].item())

# Dos operaciones de indexado seguidas: primero filtra (y eso ya devolvio una
# copia), despues asigna sobre la copia. El original ni se entera.
with warnings.catch_warnings(record=True) as avisos:
    warnings.simplefilter("always")
    inventario[inventario["cantidad"] < 0]["cantidad"] = 0
    for aviso in avisos:
        print()
        print("aviso:", aviso.category.__name__)
        print(str(aviso.message).splitlines()[0])

print()
print("P007 despues:", inventario.loc[inventario["codigo"] == "P007",
                                      "cantidad"].item())
print()
print("Lo importante: NO se levanto ninguna excepcion. Es una ADVERTENCIA.")
print("El programa siguio corriendo con el -5 intacto, y en un cuaderno")
print("de Jupyter esa advertencia se pierde entre las demas salidas.")
P007 antes: -5.0

aviso: ChainedAssignmentError
A value is being set on a copy of a DataFrame or Series through chained assignment.

P007 despues: -5.0

Lo importante: NO se levanto ninguna excepcion. Es una ADVERTENCIA.
El programa siguio corriendo con el -5 intacto, y en un cuaderno
de Jupyter esa advertencia se pierde entre las demas salidas.
La asignación encadenada no falla: no hace nada

df[df["cantidad"] < 0]["cantidad"] = 0 son dos operaciones de indexado. La primera devuelve una copia; la segunda escribe sobre esa copia, que se descarta. El -5 queda intacto.

Lo verificamos: en pandas 3.0.5 esto emite un ChainedAssignmentError como advertencia, no como excepción. El programa sigue. Si mirás la consola lo ves; si estás en un cuaderno con veinte celdas, no.

La forma correcta

EjemploLa forma correcta: una sola operacion de indexado, con loc
import pandas as pd

inventario = pd.read_csv("inventario_productos.csv")
print("P007 antes :", inventario.loc[inventario["codigo"] == "P007",
                                     "cantidad"].item())

# UN solo indexado: loc recibe las filas y la columna a la vez.
inventario.loc[inventario["cantidad"] < 0, "cantidad"] = 0

print("P007 despues:", inventario.loc[inventario["codigo"] == "P007",
                                      "cantidad"].item())
print("cantidades negativas que quedan:",
      int((inventario["cantidad"] < 0).sum()))

print()
# Y la version que no toca el original, que es la que usan los pipelines.
otro = pd.read_csv("inventario_productos.csv")
limpio = otro.assign(cantidad=otro["cantidad"].clip(lower=0))
print("con assign -> original intacto:",
      otro.loc[otro["codigo"] == "P007", "cantidad"].item())
print("            copia corregida  :",
      limpio.loc[limpio["codigo"] == "P007", "cantidad"].item())
P007 antes : -5.0
P007 despues: 0.0
cantidades negativas que quedan: 0

con assign -> original intacto: -5.0
            copia corregida  : 0.0

La regla es una sola: un solo indexado. loc recibe las filas y la columna a la vez, así que pandas sabe que estás escribiendo sobre el DataFrame original y no sobre una copia intermedia.

Modificar celdas: las tres formas
ExpresiónQué pasaCuándo
df[mask]["c"] = vNada. Advertencia y sigueNunca
df.loc[mask, "c"] = vModifica dfCuando querés modificar en el lugar
df.assign(c=...)Devuelve una tabla nuevaEn pipelines y funciones puras

Columnas derivadas de una condición

Ejemplonp.where y las columnas categoricas derivadas
import numpy as np
import pandas as pd

inventario = pd.read_csv("inventario_productos.csv")

# Dos salidas segun una condicion: np.where es el if vectorizado.
inventario["etiqueta"] = np.where(inventario["precio_unitario"] > 500,
                                  "caro", "barato")

# Mas de dos: pd.cut corta un rango en tramos con nombre.
inventario["tramo"] = pd.cut(
    inventario["precio_unitario"],
    bins=[-np.inf, 100, 500, np.inf],
    labels=["bajo", "medio", "alto"],
)

print(inventario[["codigo", "precio_unitario", "etiqueta", "tramo"]]
      .head(6).to_string(index=False))

print()
print(inventario["tramo"].value_counts(dropna=False).to_string())

print()
print("Ojo con los 2 precios ausentes: np.where los mando a 'barato'")
print("porque NaN > 500 da False, y pd.cut los dejo como NaN.")
print("Dos funciones, dos criterios distintos para el mismo ausente.")
codigo  precio_unitario etiqueta tramo
  P001             4.53   barato  bajo
  P002           781.78     caro  alto
  P003           872.23     caro  alto
  P004           432.95   barato medio
  P005           713.42     caro  alto
  P006           542.80     caro  alto

tramo
alto     17
medio     9
bajo      7
NaN       2

Ojo con los 2 precios ausentes: np.where los mando a 'barato'
porque NaN > 500 da False, y pd.cut los dejo como NaN.
Dos funciones, dos criterios distintos para el mismo ausente.

Mirá el final: np.where mandó los precios ausentes a "barato" - porque NaN > 500 es False - y pd.cut los dejó como NaN. Dos funciones, dos criterios distintos para el mismo ausente, y ninguna de las dos avisa. Si la columna derivada va a un informe, hay que decidir cuál de los dos comportamientos querías.

ChainedAssignmentError (como advertencia)

Causa: dos indexados seguidos con una asignación al final.

Arreglo: df.loc[filas, columna] = valor. Y si querés que la advertencia te frene de verdad, corré con python -W error::FutureWarning o convertí las advertencias en errores durante el desarrollo.

ValueError: Length of values does not match length of index

Causa: se asignó una lista de Python de otro largo. Una lista se pega por posición y tiene que medir exactamente lo mismo.

Arreglo: asignar una Series, que se alinea por índice, o revisar el largo.

(sin error) la columna nueva quedó llena de NaN

Causa: se asignó una Series con otro índice - típicamente una que salió de un filtro. La alineación puso NaN donde no encontró la etiqueta.

Arreglo: calcular sobre el mismo DataFrame (assign(c=lambda d: ...)) en vez de traer una Series de afuera.

(sin error) modifiqué una copia y perdí el cambio

Causa: se trabajó sobre el resultado de un filtro y se esperaba que el original cambiara.

Arreglo: decidir cuál de las dos cosas querés. Si vas a modificar el original, loc sobre el original; si vas a trabajar aparte, .copy() explícito y no esperar que se propague.

(sin error) las filas ausentes cayeron todas del mismo lado

Causa: np.where(cond, a, b) manda al lado b todo lo que no cumple, ausentes incluidos.

Arreglo: tratarlos aparte: np.where(col.isna(), "sin dato", np.where(cond, "a", "b")), o usar pd.cut, que los deja como NaN.

Acá hay que elegir

Modificar en el lugar, o devolver una copia
  • En el lugar (df.loc[...] = ...) - explorando, con una tabla chica, cuando el original no le importa a nadie más. Es directo y se lee bien.
  • Copia (assign) - en cualquier función que vayas a reutilizar. Una función que modifica lo que recibe hace que el programa dependa del orden en que se llamaron las cosas, y eso es lo primero que se rompe cuando alguien agrega un paso.

La regla que usa el curso: dentro de una función, siempre copia. El costo de memoria es real pero casi nunca importa en estos tamaños; el costo de un error de ese tipo, sí.

Recetario

Crear y modificar columnas
ExpresiónQué haceCuándo
df["c"] = serieAgrega o pisa la columna en dfExplorando
df.assign(c=...)Devuelve una copia con la columnaFunciones puras y pipelines
df.assign(c=lambda d: ...)Puede usar columnas creadas antes en la misma cadenaEncadenar dos derivadas
df.loc[mask, "c"] = vEscribe en esas celdasLa única forma correcta de asignar filtrando
df["c"] = df["c"].round(2)Redondea la columnaAntes de escribir, por columna
np.where(cond, a, b)Dos valores según la condiciónBandera 0/1 o etiqueta binaria
pd.cut(s, bins, labels=)Tramos con nombreMás de dos categorías por rango
(cond).astype("int64")Máscara a columna 0/1Banderas para contar y agrupar
df.drop(columns=["c"])Copia sin esa columnaSacar una auxiliar antes de escribir
df.copy()Copia independienteAl entrar en una función que va a escribir

Ponete a prueba

1. ¿Que diferencia hay entre df["c"] = valores y df.assign(c=valores)?Nivel 1 · Recordar
2. ¿Que hace (df["precio"] > 500).astype("int64")?Nivel 1 · Recordar
3. Corres df[df["cantidad"] < 0]["cantidad"] = 0 en pandas 3. ¿Que pasa?Nivel 2 · Predecir la salida
4. ¿Cual es la forma correcta de poner en 0 las cantidades negativas de df?Nivel 2 · Predecir la salida
5. Queres dos columnas derivadas, y la segunda usa la primera. ¿Cual funciona?Nivel 3 · Elegir entre dos casi correctas
6. np.where(df["precio"] > 500, "caro", "barato") sobre una columna con 2 precios ausentes. ¿Que etiqueta reciben esos dos?Nivel 3 · Elegir entre dos casi correctas
7. Esta funcion se llama dos veces sobre el mismo DataFrame y la segunda vez devuelve otra cosa. ¿Por que?
def marcar(df):
    df["caro"] = df["precio"] > 500
    df["precio"] = df["precio"] * 1.21
    return df
Nivel 4 · Encontrar el defecto

Prácticas de esta lección