Añadir columnas — crear los números que el archivo no tiene
Crear nuevas columnas a partir de las existentes: aritmética de columnas completas y por qué el índice decide dónde se ubica cada valor, assign, np.where y np.select, map, pd.cut, .str y .dt, transform para comparar una fila con su grupo, y apply como último recurso.
¿Cómo quieres aprender?
El problema que vamos a resolver
La dueña de la tienda envía un mensaje: «Para cada pedido, quiero ver cuánto dinero generó, si fue un pedido grande, qué responsable está a cargo de él y qué día de la semana fue. Y para cada pedido, ¿qué proporción del dinero de su sucursal representa?»
Abres orders.csv. Tiene quantity y price, branch (cuál de las tres tiendas) y date. Ninguna de las cosas que pidió está en el archivo. Cada una de ellas tiene que crearse a partir de columnas que ya están ahí.
Si vienes de Python estándar, tus manos ya saben qué escribir: un bucle sobre las filas:
import pandas as pd
orders = pd.read_csv("orders.csv")
revenues = []
for i in range(len(orders)):
revenues.append(orders.loc[i, "quantity"] * orders.loc[i, "price"])
orders["revenue"] = revenues
print(orders[["product", "quantity", "price", "revenue"]])product quantity price revenue
0 pen 12 15.0 180.0
1 notebook 5 60.0 300.0
2 bag 2 850.0 1700.0
3 bottle 7 120.0 840.0
4 eraser 30 8.0 240.0
5 bag 1 850.0 850.0
6 pen 20 15.0 300.0
7 bottle 4 120.0 480.0Funciona. También es el hábito que este capítulo existe para sustituir, por tres razones que solo se manifiestan más adelante.
Es lento: Python ejecuta el cuerpo del bucle una vez por fila, por lo que un archivo de un millón de filas significa un millón de viajes a través del intérprete. Es largo: cuatro líneas para una sola multiplicación, y la lista solicitada tiene cinco columnas más. Y es frágil de una manera difícil de percibir. El bucle cuenta posiciones 0, 1, 2, … y luego las busca como etiquetas con .loc. Eso solo funciona mientras ambas coinciden por casualidad. Filtra la tabla primero —solo los pedidos de la sucursal north, como en el capítulo cinco— y dejarán de coincidir:
north = orders[orders["branch"] == "north"]
print(north.index.tolist())
revenues = []
for i in range(len(north)):
revenues.append(north.loc[i, "quantity"] * north.loc[i, "price"])[0, 2, 4, 7]
KeyError: 1Las filas de north conservan sus etiquetas originales 0, 2, 4, 7. El bucle pide la etiqueta 1, que corresponde a un pedido de south y no está en north, por lo que falla con un error. Versiones peores de este error ni siquiera fallan: leen silenciosamente la fila equivocada.
Pandas tiene una forma diferente de enfocar esto: no calculas una columna fila por fila; la calculas una sola vez, para toda la columna. De esa única idea trata este capítulo, y una vez que la asimilas, cada columna que pidió la dueña de la tienda se convierte en una sola línea.
Al final de este capítulo puedes
- Crear una nueva columna a partir de operaciones aritméticas en columnas completas, y explicar por qué no se necesita ningún bucle
- Predecir cómo alinea pandas los valores por índice al asignar una
Series, y evitar elNaNque causa la falta de alineación - Añadir columnas sin tocar la tabla original usando
assign() - Elegir entre dos o más valores por fila con
np.whereynp.select - Buscar valores con
.map(), clasificar números en rangos conpd.cut, y extraer fragmentos de texto y fechas con.stry.dt - Comparar cada fila con su grupo usando
groupby(...).transform(...) - Reconocer cuándo se justifica
apply(axis=1)y por qué es el último recurso - Explicar qué significa Copy-on-Write en pandas 3 para añadir columnas a una tabla filtrada
Requisitos previos: Agrupación y agregación.
Crea el archivo primero
Todos los ejemplos de este capítulo leen orders.csv, el mismo archivo utilizado desde el capítulo cuatro. Si aún no lo tienes, créalo en la carpeta de tu proyecto exactamente con estas líneas:
order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0Algunos ejemplos también usan NumPy, que se instaló junto con pandas; import numpy as np es todo lo que hace falta.
Antes de escribir el código
Una nueva columna es un pequeño programa. Cinco minutos de planificación ahorran la hora que pasarías preguntándote de dónde salió un NaN.
1. Plantea la pregunta en una sola frase, por columna. Cada columna solicitada por la dueña de la tienda se convierte en una línea del plan:
revenue— cuánto dinero generó el pedido. Creado a partir dequantity×price.size—"big"si los ingresos son al menos 500; de lo contrario,"small". Creado a partir derevenue.manager— quién está a cargo de la sucursal de donde provino el pedido. Creado a partir debranch, mediante una tabla de búsqueda.weekday— el día de la semana en que se realizó el pedido. Creado a partir dedate.branch_share— los ingresos de este pedido como proporción del total de su sucursal. Creado a partir derevenueybranch.
Observa lo único que todos tienen en común: un valor por fila. Una nueva columna siempre tiene exactamente la misma longitud que la tabla. Esa es la diferencia con el capítulo ocho, donde groupby(...).sum() daba un valor por grupo. Si lo que estás calculando tiene menos valores de los que la tabla tiene filas, todavía no es una columna.
2. Mira la entrada antes de tocarla. Las herramientas que puedes utilizar dependen de los tipos de las columnas de las que partes:
import pandas as pd
orders = pd.read_csv("orders.csv")
print(orders.shape)
print(orders.dtypes)(8, 7)
order_id int64
date str
branch str
product str
category str
quantity int64
price float64
dtype: objectOcho filas; ese número aún debe ser ocho al final. quantity es int64 y price es float64, por lo que multiplicarlos realizará aritmética real. date es str: texto que parece una fecha. El texto no tiene día de la semana, por lo que requerirá una conversión antes de poder usar .dt sobre él. Anotar esto ahora es lo que evita el AttributeError más adelante.
3. Esboza la salida y calcula una fila a mano. Antes de ejecutar nada, decide en qué deberían convertirse dos de las filas: una ordinaria y una extrema:
product quantity price revenue size
pen 12 15.0 180.0 small
bag 2 850.0 1700.0 big12 × 15.0 = 180.0, y 180 está por debajo de 500, por lo que es small. Una fila calculada a mano es una prueba: después de que el código se ejecute, si la fila 0 no muestra 180.0 y small, el código es erróneo, por muy razonable que parezca.
4. Elige la herramienta adecuada para la estructura de la regla. Casi cualquier columna nueva encaja en uno de estos casos:
- Aritmética sobre columnas o un número →
+ - * /en columnas completas. Ejemplo:quantity * price. - Uno de dos valores, según una condición →
np.where. Ejemplo:"big"o"small". - Uno de varios valores, según condiciones comprobadas en orden →
np.select. Ejemplo:"bulk","normal","few". - Un valor buscado en una tabla fija →
.map(dict). Ejemplo: sucursal → responsable. - En qué rango cae un número →
pd.cut. Ejemplo: cantidad → franja. - Un fragmento de texto → los métodos de
.str. Ejemplo: las primeras letras de la categoría. - Un fragmento de una fecha →
pd.to_datetime, luego.dt. Ejemplo: el día de la semana. - Una comparación con el propio grupo de la fila →
groupby(...).transform(...). Ejemplo: proporción respecto al total de la sucursal. - Ninguna de las anteriores →
apply(axis=1). Ejemplo: una regla demasiado irregular para el resto.
Lee la lista de arriba abajo y detente en la primera línea que encaje. El orden es deliberado: las ocho primeras trabajan en columnas completas a la vez; la última vuelve a una llamada de Python por fila.
5. Decide qué comprobarás después. Tres comprobaciones, siempre: el recuento de filas no ha cambiado; la nueva columna tiene el tipo que esperabas; y su isna().sum() es cero, o exactamente el número que puedes explicar. Además de la fila calculada a mano.
Aritmética en columnas completas
Aquí tienes la columna de ingresos al estilo de pandas:
import pandas as pd
orders = pd.read_csv("orders.csv")
orders["revenue"] = orders["quantity"] * orders["price"]
print(orders[["product", "quantity", "price", "revenue"]])product quantity price revenue
0 pen 12 15.0 180.0
1 notebook 5 60.0 300.0
2 bag 2 850.0 1700.0
3 bottle 7 120.0 840.0
4 eraser 30 8.0 240.0
5 bag 1 850.0 850.0
6 pen 20 15.0 300.0
7 bottle 4 120.0 480.0La fila 0 dice 180.0, el valor calculado a mano. Sin bucles ni aritmética de índices.
Por qué funciona esto. orders["quantity"] es una Series de ocho números; orders["price"] también lo es. Cuando colocas * entre dos Series, pandas empareja sus valores por etiqueta de índice —la etiqueta 0 con la etiqueta 0, la etiqueta 1 con la etiqueta 1— y multiplica cada par. El bucle sigue existiendo, pero dentro del código compilado de pandas, no en Python. Por eso es rápido, y también por eso no puede leer la fila equivocada: no hay ningún contador que pueda desincronizarse.
Luego, orders["revenue"] = ... hace una de dos cosas, dependiendo del nombre:
- si
revenueno existe, añade una nueva columna en el extremo derecho; - si ya existe, la sustituye silenciosamente. No hay ninguna pregunta de «¿estás seguro?».
El tipo del resultado sigue a la aritmética: int64 × float64 da como resultado float64, por lo que revenue muestra 180.0 en lugar de 180.
Una columna también se puede combinar con un único número. El número se utiliza para cada fila —pandas llama a esto broadcasting:
orders["price_with_vat"] = (orders["price"] * 1.15).round(2)
print(orders[["product", "price", "price_with_vat"]].head(4))product price price_with_vat
0 pen 15.0 17.25
1 notebook 60.0 69.00
2 bag 850.0 977.50
3 bottle 120.0 138.00Los paréntesis importan: .round(2) se aplica a todo el resultado de la multiplicación, que vuelve a ser una Series. Cada paso aquí toma una columna y devuelve una columna, por lo que puedes continuar encadenando.
El índice decide qué valor va en cada lugar
Ese emparejamiento por etiqueta no solo sirve para la aritmética. Asignar una Series a una columna también alinea por etiqueta. La mayor parte del tiempo nunca lo notas, porque los nuevos valores provienen de la misma tabla y las etiquetas coinciden exactamente. Lo notas el día en que no coinciden.
Supongamos que quieres una columna con la cantidad solo para los pedidos de la sucursal north:
north = orders[orders["branch"] == "north"]
orders["north_qty"] = north["quantity"]
print(orders[["branch", "quantity", "north_qty"]])branch quantity north_qty
0 north 12 12.0
1 south 5 NaN
2 north 2 2.0
3 east 7 NaN
4 north 30 30.0
5 south 1 NaN
6 east 20 NaN
7 north 4 4.0north tiene las etiquetas 0, 2, 4, 7. Pandas colocó cada valor junto a la fila con la misma etiqueta, y cada una de las demás filas recibió NaN —«sin valor para esta etiqueta»—. Y dado que NaN es un número decimal (float), la columna se convirtió en float64 a pesar de que las cantidades son números enteros. Eso puede ser exactamente lo que querías, o el inicio de una larga búsqueda de datos ausentes que tú mismo creaste. La forma de saberlo es conocer la regla: la asignación hace coincidir etiquetas, no posiciones.
La misma regla te protege. Ordenar (capítulo siete) reordena una Series pero mantiene cada valor con su etiqueta, por lo que reasignar una Series ordenada coloca cada valor en su fila original:
by_revenue = orders["revenue"].sort_values()
print(by_revenue.head(3))
orders["revenue_copy"] = by_revenue
print(orders[["product", "revenue", "revenue_copy"]].head(3))
print((orders["revenue_copy"] == orders["revenue"]).all())0 180.0
4 240.0
6 300.0
Name: revenue, dtype: float64
product revenue revenue_copy
0 pen 180.0 180.0
1 notebook 300.0 300.0
2 bag 1700.0 1700.0
TrueLa Series ordenada sigue el orden de las etiquetas 0, 4, 6: el segundo valor, 240.0, pertenece a la fila 4, no a la fila 1. Y sin embargo, revenue_copy es idéntica a revenue fila por fila, lo cual confirma la última línea para las ocho filas. Pandas utilizó las etiquetas e ignoró el orden. Si hubiera seguido las posiciones, la fila 1 (el cuaderno, 300.0) habría recibido 240.0.
Una lista estándar de Python no tiene etiquetas, por lo que se utiliza por posición; y en ese caso, su longitud debe coincidir con exactitud:
try:
orders["rating"] = [5, 4, 3]
except ValueError as error:
print("ValueError:", error)ValueError: Length of values (3) does not match length of index (8)Asignar tres valores para ocho filas se rechaza en lugar de intentar adivinar. Ese es el comportamiento más seguro posible, y vale la pena verlo una vez para familiarizarse con el mensaje.
Ya no necesitamos las columnas de prueba. drop(columns=...) devuelve la tabla sin ellas:
orders = orders.drop(columns=["north_qty", "revenue_copy", "price_with_vat"])
print(list(orders.columns))['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'revenue']assign(): nuevas columnas sin modificar la original
orders["revenue"] = ... modifica la propia orders. A menudo eso es lo que quieres. A veces no: quieres probar algunas columnas, o construir una tabla de informe, y mantener la original exactamente como se leyó. assign() devuelve un nuevo DataFrame con las columnas adicionales y deja el original intacto:
import numpy as np
import pandas as pd
orders = pd.read_csv("orders.csv")
report = orders.assign(
revenue=orders["quantity"] * orders["price"],
vat=lambda d: (d["revenue"] * 0.15).round(2),
)
print(report[["product", "revenue", "vat"]].head(3))
print("revenue" in orders.columns)product revenue vat
0 pen 180.0 27.0
1 notebook 300.0 45.0
2 bag 1700.0 255.0
FalseTres cosas que observar. (El import numpy as np en la parte superior es para la siguiente sección).
Los nombres de los argumentos se convierten en los nombres de las columnas: revenue=... crea una columna llamada revenue. Por eso los nombres de columna deben ser nombres válidos de Python cuando usas assign().
vat se escribe como una función lambda. Tiene que ser así: vat necesita revenue, y revenue no existe en orders; solo existe en la nueva tabla que se está construyendo. Una función lambda d: ... recibe esa nueva tabla como d, por lo que d["revenue"] ya está disponible. Dentro de un mismo assign(), las columnas se añaden de arriba hacia abajo, y cada lambda ve las que están por encima de ella.
Y la última línea imprime False: orders no tiene ninguna columna revenue. Nada se modificó a tus espaldas.
np.where: uno de dos valores
«big si los ingresos son al menos 500; de lo contrario, small» es un if/else para cada fila. Escribir un if significaría volver a un bucle, porque un if necesita un único True o False, y la comparación de una columna entera proporciona ocho de ellos (ese es el error «truth value of a Series is ambiguous» del capítulo cinco).
El where de NumPy es un if/else que opera sobre una columna completa: donde la condición sea True, toma el primer valor; en el resto de los casos, el segundo.
report["size"] = np.where(report["revenue"] >= 500, "big", "small")
print(report[["product", "revenue", "size"]])product revenue size
0 pen 180.0 small
1 notebook 300.0 small
2 bag 1700.0 big
3 bottle 840.0 big
4 eraser 240.0 small
5 bag 850.0 big
6 pen 300.0 small
7 bottle 480.0 smallLa fila 0 es small, la fila 2 es big: ambas coinciden con el esbozo. La condición es el mismo tipo de máscara booleana que creaste en el capítulo cinco; np.where simplemente convierte cada True/False en un valor. Conviene comprobar el límite: >= 500 significa que un pedido de exactamente 500 es big. Decide eso a propósito, no por accidente.
Más de dos valores: np.select
Tres niveles según la cantidad: 20 o más es "bulk", 5 o más es "normal", cualquier otra cosa es "few". np.select toma una lista de condiciones y una lista de valores correspondientes, junto con un default para las filas que no cumplan ninguna:
conditions = [
report["quantity"] >= 20,
report["quantity"] >= 5,
]
labels = ["bulk", "normal"]
report["tier"] = np.select(conditions, labels, default="few")
print(report[["product", "quantity", "tier"]])product quantity tier
0 pen 12 normal
1 notebook 5 normal
2 bag 2 few
3 bottle 7 normal
4 eraser 30 bulk
5 bag 1 few
6 pen 20 bulk
7 bottle 4 fewLa primera condición que sea True es la que gana. Por eso la condición más estricta va primero. Invierte el orden y observa qué ocurre con el pedido de 30 gomas de borrar:
wrong = np.select(
[report["quantity"] >= 5, report["quantity"] >= 20],
["normal", "bulk"],
default="few",
)
print(wrong)['normal' 'normal' 'few' 'normal' 'normal' 'few' 'normal' 'few']Cada pedido es normal o few; ninguno es bulk, porque 30 y 20 también son >= 5 y esa condición se comprobó primero. Ningún error, simplemente una columna equivocada: por eso la fila calculada a mano del plan debería incluir un valor extremo.
.map(): buscar valores
El responsable no se calcula a partir de nada. Es un dato conocido: Asha dirige la sucursal north, Omar la de south, Lina la de east. Cuando la regla es «búscalo», escribe la tabla de búsqueda como un diccionario y pasa la columna a través de él con .map():
manager_of = {
"north": "Asha",
"south": "Omar",
"east": "Lina",
}
report["manager"] = report["branch"].map(manager_of)
print(report[["branch", "manager"]])
print(report["manager"].isna().sum())branch manager
0 north Asha
1 south Omar
2 north Asha
3 east Lina
4 north Asha
5 south Omar
6 east Lina
7 north Asha
0Cada sucursal se sustituyó por su valor en el diccionario: el nombre del responsable. El segundo print es la comprobación del plan, y tiene más importancia de la que parece, debido a lo que hace .map() con una clave que no puede encontrar. Imagina que el diccionario se hubiera escrito antes de que abriera la sucursal east:
old_managers = {"north": "Asha", "south": "Omar"}
print(report["branch"].map(old_managers).isna().sum())2Ningún error. Los dos pedidos de east simplemente recibieron NaN. En ocho filas lo notarías; en ochenta mil no lo notarías, y un posterior groupby("manager") omitiría silenciosamente esos pedidos (capítulo ocho: las claves NaN se descartan). Cada .map() lleva un isna().sum() justo después.
pd.cut: en qué rango cae un número
La tienda quiere clasificar cada pedido por franjas según la cantidad: hasta 5 artículos es small, de 6 a 15 es medium, más de 15 es large. Podrías escribir eso con np.select, pero «en qué rango cae» tiene su propia herramienta. pd.cut toma los límites de los rangos y una etiqueta para cada rango:
report["band"] = pd.cut(
report["quantity"],
bins=[0, 5, 15, np.inf],
labels=["small", "medium", "large"],
)
print(report[["product", "quantity", "band"]])product quantity band
0 pen 12 medium
1 notebook 5 small
2 bag 2 small
3 bottle 7 medium
4 eraser 30 large
5 bag 1 small
6 pen 20 large
7 bottle 4 smallCuatro límites forman tres rangos: (0, 5], (5, 15] y (15, ∞]. El paréntesis curvo significa «sin incluir», el corchete significa «incluyendo», por lo que cada rango incluye su límite derecho. Por eso el pedido del cuaderno de exactamente 5 es small, no medium. np.inf es el infinito, un límite superior que ninguna cantidad puede superar.
El resultado tiene un tipo que aún no has conocido:
print(report["band"].dtype)
print(report["band"].cat.categories.tolist())category
['small', 'medium', 'large']Una columna de tipo category recuerda que small < medium < large. Por tanto, ordenar por band da ese orden en lugar del orden alfabético: por lo general, lo que busca un informe.
Texto y fechas: .str y .dt
Fragmentos de texto
El capítulo cinco utilizó .str.contains para filtrar. El mismo accesor .str construye columnas: cada método de cadenas que conoces de Python, aplicado a todos los valores a la vez. Supongamos que cada pedido necesita un código corto como STA-1001: las tres primeras letras de la categoría, en mayúsculas, un guion y luego el número de pedido:
report["code"] = (
report["category"].str[:3].str.upper()
+ "-"
+ report["order_id"].astype(str)
)
print(report[["category", "order_id", "code"]].head(3))category order_id code
0 stationery 1001 STA-1001
1 stationery 1002 STA-1002
2 accessories 1003 ACC-1003.str[:3] corta cada valor, .str.upper() convierte cada valor a mayúsculas, y + entre columnas de texto las concatena fila por fila. order_id es un número, y un número no se puede sumar a un texto, por lo que .astype(str) lo convierte primero en texto.
Fragmentos de una fecha
El plan ya advirtió que date es str. Conviértela con pd.to_datetime y se habilitará el accesor .dt:
report["date"] = pd.to_datetime(report["date"])
print(report["date"].dtype)
report["weekday"] = report["date"].dt.day_name()
print(report[["date", "weekday"]].head(4))datetime64[us]
date weekday
0 2024-03-01 Friday
1 2024-03-01 Friday
2 2024-03-02 Saturday
3 2024-03-02 SaturdayLa columna ahora es datetime64: una fecha real, no texto. (El [us] es la precisión que pandas 3 eligió para ella; puedes ignorarlo). A partir de una fecha real, .dt te ofrece .dt.day_name(), .dt.month, .dt.year, .dt.day y más. Y la conversión se realiza una sola vez, asignándola de nuevo a date, para que cada paso posterior reciba una fecha, no una cadena de texto.
transform: comparar una fila con su grupo
La última petición —la proporción de los ingresos de cada pedido respecto a su sucursal— necesita dos cosas en la misma fila: los ingresos del pedido y el total de su sucursal. El capítulo ocho nos dio los totales:
print(report.groupby("branch")["revenue"].sum())branch
east 1140.0
north 2600.0
south 1150.0
Name: revenue, dtype: float64Pero eso son tres valores, uno por sucursal, y la tabla tiene ocho filas. No puedes dividir ocho números entre tres. Lo que necesitas es el total de la sucursal repetido en cada fila de esa sucursal. Eso es exactamente lo que devuelve transform:
branch_total = report.groupby("branch")["revenue"].transform("sum")
print(branch_total)0 2600.0
1 1150.0
2 2600.0
3 1140.0
4 2600.0
5 1150.0
6 1140.0
7 2600.0
Name: revenue, dtype: float64La misma agrupación, el mismo "sum", pero el resultado tiene ocho valores con las propias etiquetas de la tabla: cada fila de north lleva 2600, cada fila de south 1150. Ahora es aritmética de columnas ordinaria:
report["branch_share"] = (report["revenue"] / branch_total).round(3)
print(report[["branch", "product", "revenue", "branch_share"]])branch product revenue branch_share
0 north pen 180.0 0.069
1 south notebook 300.0 0.261
2 north bag 1700.0 0.654
3 east bottle 840.0 0.737
4 north eraser 240.0 0.092
5 south bag 850.0 0.739
6 east pen 300.0 0.263
7 north bottle 480.0 0.185Compruébalo: el pedido de la bolsa en la sucursal north es 1700 / 2600 = 0.654. Y las proporciones dentro de cada sucursal deben sumar 1:
print(report.groupby("branch")["branch_share"].sum())branch
east 1.0
north 1.0
south 1.0
Name: branch_share, dtype: float64La regla para recordar: agg/sum reducen un grupo a una sola fila; transform devuelve un valor para cada fila. Usa el primero para una tabla de resumen, el segundo para una columna nueva.
apply(axis=1): el último recurso
A veces la regla es realmente irregular. La tarifa de entrega es: en la sucursal north —la principal, junto al almacén—, gratis cuando los ingresos son de al menos 500; de lo contrario, 60; en las demás sucursales, 120, pero gratis por encima de 1000. Puedes escribir eso como una función ordinaria de Python y aplicarla a cada fila con apply:
def delivery_fee(row):
if row["branch"] == "north":
return 0 if row["revenue"] >= 500 else 60
return 0 if row["revenue"] > 1000 else 120
report["fee"] = report.apply(delivery_fee, axis=1)
print(report[["branch", "revenue", "fee"]])branch revenue fee
0 north 180.0 60
1 south 300.0 120
2 north 1700.0 0
3 east 840.0 120
4 north 240.0 60
5 south 850.0 120
6 east 300.0 120
7 north 480.0 60axis=1 significa «pasa a la función una fila a la vez». Por dentro, row es una Series cuyas etiquetas son los nombres de las columnas, por lo que row["branch"] funciona.
Es fácil de leer, y ese es todo su atractivo. El coste es que volvemos a tener el bucle del principio del capítulo: pandas crea una Series para cada fila y llama a tu función de Python una vez por fila. En ocho filas eso no es nada; en un millón de filas es la diferencia entre un parpadeo y una pausa para el café.
Así que antes de recurrir a apply, intenta expresar la regla con herramientas que operen sobre columnas completas. Esta regla es en realidad «tres casos, comprobados en orden»: np.select:
in_north = report["branch"] == "north"
fee_fast = np.select(
[in_north & (report["revenue"] >= 500), in_north, report["revenue"] > 1000],
[0, 60, 0],
default=120,
)
print((fee_fast == report["fee"]).all())TrueResultado idéntico, sin llamadas de Python por fila. Reserva apply(axis=1) para reglas que genuinamente no se puedan escribir de esta manera —llamar a una función externa, analizar una cadena desordenada en varios pasos— y considera el impulso de usarlo como una señal para reflexionar una vez más.
Añadir una columna a una tabla filtrada: Copy-on-Write
A menudo filtrarás primero y luego añadirás una columna al resultado. En pandas 3 eso es sencillo, y vale la pena comprender por qué es sencillo, ya que los tutoriales más antiguos dicen lo contrario.
import pandas as pd
orders = pd.read_csv("orders.csv")
orders["revenue"] = orders["quantity"] * orders["price"]
north = orders[orders["branch"] == "north"]
north["revenue_k"] = north["revenue"] / 1000
print(north[["product", "revenue", "revenue_k"]])
print(list(orders.columns))product revenue revenue_k
0 pen 180.0 0.18
2 bag 1700.0 1.70
4 eraser 240.0 0.24
7 bottle 480.0 0.48
['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'revenue']Sin advertencias, y orders no recibió ninguna columna revenue_k. Pandas 3 utiliza Copy-on-Write: cada resultado de un filtrado o selección se comporta como una tabla independiente propia. Modificar north nunca puede alterar orders.
Si lees código más antiguo o respuestas en internet, verás este patrón exacto seguido de un SettingWithCopyWarning («A value is trying to be set on a copy of a slice from a DataFrame»), y el consejo de escribir orders[...].copy(). Esa advertencia existía porque en versiones antiguas de pandas a veces sí se compartían datos entre ambas tablas, y nadie podía saber fácilmente cuándo ocurría. Pandas 3 eliminó esa incertidumbre, y la advertencia junto con ella. Escribir .copy() ahora es innecesario, aunque inofensivo.
Lo que Copy-on-Write no permite es modificar orders a través de una cadena de dos pasos. Si quieres modificar valores en la tabla original para ciertas filas, esto no hace nada:
orders[orders["branch"] == "north"]["revenue"] = 0
print(orders["revenue"].tolist())[180.0, 300.0, 1700.0, 840.0, 240.0, 850.0, 300.0, 480.0]
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment.
Such chained assignment never works to update the original DataFrame or Series, because the intermediate object on which we are setting values always behaves as a copy (due to Copy-on-Write).
Try using '.loc[row_indexer, col_indexer] = value' instead, to perform the assignment in a single step.
See the documentation for a more detailed explanation: https://pandas.pydata.org/pandas-docs/stable/user_guide/copy_on_write.html#chained-assignmentEl primer corchete crea una nueva tabla, el segundo corchete establece valores en esa nueva tabla, y la nueva tabla se descarta. Pandas te advierte con un ChainedAssignmentError, y la lista muestra revenue sin cambios: los pedidos de north siguen diciendo 180.0, 1700.0, 240.0 y 480.0. (Las advertencias van al flujo de errores, por lo que en tu terminal la advertencia puede aparecer encima de la lista en lugar de debajo). Para modificar la original, hazlo en un solo paso con .loc[rows, column]:
orders.loc[orders["branch"] == "north", "note"] = "main branch"
print(orders[["branch", "note"]])branch note
0 north main branch
1 south NaN
2 north main branch
3 east NaN
4 north main branch
5 south NaN
6 east NaN
7 north main branchLas filas coincidentes reciben el valor; la columna se crea si es necesario; las filas que no coinciden reciben NaN. Un solo paso, la tabla original, sin advertencias.
Un ejemplo completo
enrich.py responde al mensaje completo de la dueña de la tienda, siguiendo el plan: leer, comprobar, añadir columnas, comprobar de nuevo.
import numpy as np
import pandas as pd
MANAGER_OF = {
"north": "Asha",
"south": "Omar",
"east": "Lina",
}
orders = pd.read_csv("orders.csv")
rows_before = len(orders)
# 1. Conversions first, so later steps get the right types.
orders["date"] = pd.to_datetime(orders["date"])
# 2. New columns, one rule each.
orders["revenue"] = orders["quantity"] * orders["price"]
orders["size"] = np.where(orders["revenue"] >= 500, "big", "small")
orders["manager"] = orders["branch"].map(MANAGER_OF)
orders["weekday"] = orders["date"].dt.day_name()
branch_total = orders.groupby("branch")["revenue"].transform("sum")
orders["branch_share"] = (orders["revenue"] / branch_total).round(3)
# 3. Checks: same rows, nothing missing, the hand-worked row is right.
new_columns = ["revenue", "size", "manager", "weekday", "branch_share"]
assert len(orders) == rows_before
assert orders[new_columns].isna().sum().sum() == 0
assert orders.loc[0, "revenue"] == 180.0
print(orders[["order_id", "branch", "manager", "weekday", "revenue", "size", "branch_share"]])
print()
print(orders[new_columns].dtypes)order_id branch manager weekday revenue size branch_share
0 1001 north Asha Friday 180.0 small 0.069
1 1002 south Omar Friday 300.0 small 0.261
2 1003 north Asha Saturday 1700.0 big 0.654
3 1004 east Lina Saturday 840.0 big 0.737
4 1005 north Asha Sunday 240.0 small 0.092
5 1006 south Omar Sunday 850.0 big 0.739
6 1007 east Lina Monday 300.0 small 0.263
7 1008 north Asha Monday 480.0 small 0.185
revenue float64
size str
manager str
weekday str
branch_share float64
dtype: objectPor qué está escrito de esta manera:
- La conversión va primero.
datese transforma en una fecha real antes de que nada la utilice, de modo que.dtno pueda fallar más adelante y nadie en los pasos siguientes tenga que recordar convertirla. - Una regla por línea. Cada línea responde a una línea del plan, por lo que cuando un número parece incorrecto, sabes qué línea exacta lo produjo.
- La tabla de búsqueda es una constante con nombre en la parte superior. Cuando la tienda abra una sucursal en west, el cambio requerirá una sola línea en un lugar evidente, y la comprobación de
isnafallará ruidosamente si a alguien se le olvida. - Las comprobaciones son
assert, no llamadas aprint. Si el recuento de filas cambia, un.map()pasa por alto una sucursal o la fila 0 deja de ser 180, el programa se detiene en lugar de imprimir un informe incorrecto que parece correcto. - Se imprime
dtypesal final porque el tipo es parte de la respuesta:revenuedebe ser un decimal (float),branch_shareun decimal, las columnas de textostr.
Cuando algo falla
KeyError: 'Price' Un nombre de columna en el lado derecho está mal escrito o tiene las mayúsculas y minúsculas incorrectas. Imprime list(orders.columns) y copia el nombre desde allí. Presta atención también al lado izquierdo: orders["revenu"] = ... no genera ningún error: crea una columna nueva y mal escrita junto a la que pretendías. Si una columna que «actualizaste» no ha cambiado, busca una gemela con una errata.
ValueError: Length of values (3) does not match length of index (8) Has asignado una lista (o array) cuya longitud no coincide con el número de filas. Una lista no tiene etiquetas, por lo que pandas no puede alinearla y la rechaza. O bien construye los valores a partir de las propias columnas de la tabla, o comprueba primero len(values) == len(df).
La nueva columna está llena de NaN en algunas filas, y el tipo se convirtió en float64 Has asignado una Series cuyas etiquetas solo coinciden parcialmente con la tabla; normalmente, una creada a partir de una tabla filtrada. La asignación hace coincidir etiquetas, no posiciones. Construye la nueva columna a partir de la tabla completa (con np.where o .loc[mask, "col"] = ...) en lugar de hacerlo a partir de un subconjunto.
revenue contiene cosas como 15.015.015.0…, o TypeError: can't multiply sequence by non-int of type 'float' Una de las columnas es texto, no un número. Multiplicar un int por una cadena repite la cadena —la regla de Python 3 * "ab" == "ababab"— y pandas la aplica fila por fila sin quejarse:
import pandas as pd
from io import StringIO
RAW = """product,quantity,price
pen,12,15.0
bag,2,"1,200"
"""
bad = pd.read_csv(StringIO(RAW))
print(bad.dtypes)
print(bad["quantity"] * bad["price"])product str
quantity int64
price str
dtype: object
0 15.015.015.015.015.015.015.015.015.015.015.015.0
1 1,2001,200
dtype: strLa coma de los millares en "1,200" convirtió toda la columna price en str. Por eso el plan dice que mires dtypes antes de la aritmética. Corrígelo donde se lee, con thousands=",", o conviértelo con pd.to_numeric:
good = pd.read_csv(StringIO(RAW), thousands=",")
print(good.dtypes["price"])
print(good["quantity"] * good["price"])float64
0 180.0
1 2400.0
dtype: float64AttributeError: Can only use .dt accessor with datetimelike values La columna sigue siendo texto. Conviértela primero con df["date"] = pd.to_datetime(df["date"]) y utiliza .dt después de eso.
KeyError: 'quantity' al usar apply Olvidaste axis=1. Sin él, apply pasa columnas completas a tu función, por lo que row["quantity"] busca una fila etiquetada como quantity dentro de una columna y falla. Añade axis=1, y luego pregúntate si realmente necesitas apply.
IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer Llamaste a .astype(int) en una columna que contiene NaN. Un entero de NumPy no tiene forma de almacenar «ausente». Rellena los huecos primero (capítulo seis), o usa el tipo de entero anulable de pandas, .astype("Int64") con I mayúscula, que sí puede albergar valores ausentes.
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. Escribiste df[mask]["col"] = value. Bajo Copy-on-Write, el primer paso crea una tabla independiente, por lo que la original nunca cambia. Escríbelo en un solo paso: df.loc[mask, "col"] = value.
Una columna con .map() tiene NaN donde esperabas un valor Una clave de la columna no está en el diccionario: una sucursal nueva, una ortografía diferente, un espacio al final. Encuéntralas con df.loc[df["new"].isna(), "key_column"].unique(), y luego corrige el diccionario o los datos.
Paso 4 de 6 — Predice
Comprueba lo que has entendido
Una columna creada a partir de los pedidos de north se asigna a la tabla completa. ¿Qué se imprime?
from io import StringIO
import pandas as pd
# Stands in for orders.csv, so this snippet runs on its own.
RAW = """order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0
"""
orders = pd.read_csv(StringIO(RAW))
north = orders[orders["branch"] == "north"]
orders["north_qty"] = north["quantity"]
print(orders["north_qty"].isna().sum(), orders["north_qty"].dtype)- A4 int64
- B0 int64
- C0 float64
- D4 float64
El objetivo es añadir una columna revenue a orders. ¿Qué se imprime?
from io import StringIO
import pandas as pd
# Stands in for orders.csv, so this snippet runs on its own.
RAW = """order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0
"""
orders = pd.read_csv(StringIO(RAW))
orders.assign(revenue=orders["quantity"] * orders["price"])
print("revenue" in orders.columns)- A`False` — `assign` devuelve una tabla nueva y deja `orders` sin cambios, y el resultado no se conservó
- BUn `KeyError: 'revenue'`
- CUn `ChainedAssignmentError`
- D`True`
Necesitas una columna que, en cada fila, contenga la proporción de ese pedido sobre la cantidad total de su sucursal. ¿Qué línea da un valor por fila?
- Aorders["quantity"] / orders.groupby("branch")["quantity"].sum()
- Borders["quantity"] / orders.groupby("branch")["quantity"].transform("sum")
- Corders.groupby("branch")["quantity"].sum() / orders["quantity"]
- Dorders["quantity"] / orders["quantity"].sum()
Para responder necesitas una cuenta
Inicia sesión para comprobar tus respuestas
Las preguntas están arriba, y resolverlas mentalmente es lo que de verdad importa. Inicia sesión para ver las respuestas, las explicaciones y las pistas de tres niveles.
Tu turno
Usando el mismo orders.csv, escribe enrich_task.py para entregarle a la dueña de la tienda un informe de descuentos. Planifica primero —escribe la lista de nuevas columnas y calcula la fila 0 a mano—; luego añade estas columnas, en este orden:
revenue=quantity×pricediscount= 10% derevenuecuandoquantitysea 10 o más; de lo contrario, 0net=revenue−discountmanager— a partir de la sucursal, usando el diccionario de este capítuloband— a partir denet: hasta 300 es"low", más de 300 hasta 800 es"mid", más de 800 es"high"weekday— a partir dedatecategory_share— elnetde cada pedido como proporción delnettotal de su categoría, redondeado a 2 decimales
Imprime la forma (shape) antes y después, y luego order_id, product, quantity, net, band, weekday y category_share, ordenados por net de mayor a menor.
Tu programa es correcto cuando:
- Imprime exactamente la salida que se muestra a continuación.
- No hay ningún bucle
forniapplyen ninguna parte: cada columna se genera a partir de columnas completas a la vez. - Si falta alguna sucursal en el diccionario, el programa se detiene con un
AssertionErroren lugar de continuar conNaN. - El recuento de filas es 8 tanto antes como después.
Salida esperada:
before: (8, 7)
after: (8, 14)
order_id product quantity net band weekday category_share
2 1003 bag 2 1700.0 high Saturday 0.44
5 1006 bag 1 850.0 high Sunday 0.22
3 1004 bottle 7 840.0 high Saturday 0.22
7 1008 bottle 4 480.0 mid Monday 0.12
1 1002 notebook 5 300.0 low Friday 0.32
6 1007 pen 20 270.0 low Monday 0.28
4 1005 eraser 30 216.0 low Sunday 0.23
0 1001 pen 12 162.0 low Friday 0.17Antes de ejecutarlo, predice: ¿qué pedido tiene el mayor net, y en qué franja se encuentra el pedido de 30 gomas de borrar? Luego compruébalo con la salida.
Solución
El plan. Fila 0 a mano: el pedido de bolígrafos, 12 × 15.0 = 180.0; la cantidad es 12, por lo que el descuento es 18.0 y net es 162.0; 162 es a lo sumo 300, por lo que es low. El pedido de gomas de borrar: 30 × 8.0 = 240.0, descuento 24.0, net 216.0 —también low, aunque sea el pedido más grande en cantidad—. El net más alto debería ser el pedido de 2 bolsas: 1700.0, sin descuento.
revenueynet— aritmética de columnas: multiplicación y resta directas.discount—np.where: uno de dos valores por fila.manager—.map(dict), seguido deisna().sum(): una búsqueda, comprobada.band—pd.cutcon límites[0, 300, 800, inf]: rangos. Cada rango incluye su extremo derecho, por lo que exactamente 300 eslowy exactamente 800 esmid.weekday—pd.to_datetime, luego.dt.day_name(): los componentes de una fecha necesitan una fecha real.category_share—groupby("category")["net"].transform("sum"): un valor por fila, comparado con su grupo.
enrich_task.py:
import numpy as np
import pandas as pd
MANAGER_OF = {
"north": "Asha",
"south": "Omar",
"east": "Lina",
}
orders = pd.read_csv("orders.csv")
print("before:", orders.shape)
orders["revenue"] = orders["quantity"] * orders["price"]
orders["discount"] = np.where(orders["quantity"] >= 10, orders["revenue"] * 0.10, 0.0)
orders["net"] = orders["revenue"] - orders["discount"]
orders["manager"] = orders["branch"].map(MANAGER_OF)
assert orders["manager"].isna().sum() == 0
orders["band"] = pd.cut(
orders["net"],
bins=[0, 300, 800, np.inf],
labels=["low", "mid", "high"],
)
orders["date"] = pd.to_datetime(orders["date"])
orders["weekday"] = orders["date"].dt.day_name()
category_total = orders.groupby("category")["net"].transform("sum")
orders["category_share"] = (orders["net"] / category_total).round(2)
print("after: ", orders.shape)
print()
columns = ["order_id", "product", "quantity", "net", "band", "weekday", "category_share"]
print(orders[columns].sort_values("net", ascending=False))before: (8, 7)
after: (8, 14)
order_id product quantity net band weekday category_share
2 1003 bag 2 1700.0 high Saturday 0.44
5 1006 bag 1 850.0 high Sunday 0.22
3 1004 bottle 7 840.0 high Saturday 0.22
7 1008 bottle 4 480.0 mid Monday 0.12
1 1002 notebook 5 300.0 low Friday 0.32
6 1007 pen 20 270.0 low Monday 0.28
4 1005 eraser 30 216.0 low Sunday 0.23
0 1001 pen 12 162.0 low Friday 0.17Al contrastarlo con el plan:
- La forma (
shape) pasó de 7 columnas a 14 y se mantuvo en 8 filas: siete columnas nuevas, ninguna fila ganada o perdida. - La fila 0 (pedido 1001) tiene un
netde162.0y una franjalow: los valores calculados a mano. - El mayor
netes el pedido 1003, la bolsa, con1700.0, tal como se predijo. El pedido de gomas de borrar 1005 eslowcon216.0: la mayor cantidad de artículos, pero baratos. np.wheretomó una columna como su valor para «verdadero» —orders["revenue"] * 0.10—, por lo que cada fila recibió su propio 10%, no un único número. Ambas ramas denp.wherepueden ser columnas o valores simples.- El límite de una franja es visible. El pedido del cuaderno tiene un
netde exactamente300.0y eslow, porque(0, 300]incluye el 300. Si la dueña de la tienda se refería a «de 300 en adelante es mid», los límites deben cambiar: una decisión, no un detalle. - Las proporciones suman 1 dentro de cada categoría. Accessories:
0.44 + 0.22 + 0.22 + 0.12 = 1.00. Stationery:0.32 + 0.28 + 0.23 + 0.17 = 1.00. Si las proporciones de una categoría no sumaran 1, eltransformse habría agrupado por la columna equivocada.
Luego rómpelo a propósito, una vez para cada caso: elimina "east" del diccionario y observa cómo el assert detiene el programa; cambia bins para que comience en 200 en lugar de 0 y mira qué filas reciben NaN en band, y por qué.
Paso 6 de 6
Reto — el quiz del capítulo
Diez preguntas de fácil a difícil. Las últimas son difíciles a propósito.
Inicia sesión para hacer el quiz