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
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
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
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
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.
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
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.
| Expresión | Qué pasa | Cuándo |
|---|---|---|
df[mask]["c"] = v | Nada. Advertencia y sigue | Nunca |
df.loc[mask, "c"] = v | Modifica df | Cuando querés modificar en el lugar |
df.assign(c=...) | Devuelve una tabla nueva | En pipelines y funciones puras |
Columnas derivadas de una condición
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.
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.
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.
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.
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.
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
- 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
| Expresión | Qué hace | Cuándo |
|---|---|---|
df["c"] = serie | Agrega o pisa la columna en df | Explorando |
df.assign(c=...) | Devuelve una copia con la columna | Funciones puras y pipelines |
df.assign(c=lambda d: ...) | Puede usar columnas creadas antes en la misma cadena | Encadenar dos derivadas |
df.loc[mask, "c"] = v | Escribe en esas celdas | La única forma correcta de asignar filtrando |
df["c"] = df["c"].round(2) | Redondea la columna | Antes de escribir, por columna |
np.where(cond, a, b) | Dos valores según la condición | Bandera 0/1 o etiqueta binaria |
pd.cut(s, bins, labels=) | Tramos con nombre | Más de dos categorías por rango |
(cond).astype("int64") | Máscara a columna 0/1 | Banderas para contar y agrupar |
df.drop(columns=["c"]) | Copia sin esa columna | Sacar una auxiliar antes de escribir |
df.copy() | Copia independiente | Al entrar en una función que va a escribir |