Capítulo 2 de 16 8 secciones 14 min

Cada columna es de un tipo, y eso decide qué puedes calcular

Nominal, ordinal, discreta y continua. Y las cuentas que salen sin dar error y no significan nada.

Hay cuatro tipos de variable y cada uno admite unas medidas y prohíbe otras: nominal (categorías sin orden, solo moda y frecuencias), ordinal (categorías con orden, mediana), discreta (números contables) y continua (cualquier valor de un rango, media y desviación). El peligro es que el software no conoce esta diferencia: describe() calcula alegremente la media de un número de factura y devuelve 11499.50, que no significa nada.

En el capítulo 1 vimos que las ventas se mueven 14,41% al mes, y que había nueve ciudades donde tenía que haber seis 🙃

Este capítulo arregla eso y contesta una pregunta que parece de manual y es de las más prácticas del libro: ¿qué puedo calcular con cada columna?

Porque tu computadora no lo sabe. Le pides la media de lo que sea y te la da. El problema es que la mitad de esas medias no significan nada, y no hay ningún mensaje de error que te avise 😶

Primero la limpieza, que si no todo lo demás miente

Tres cosas por orden.

Uno: las filas repetidas. Se van primero, porque una fila duplicada cuenta dos veces en todas las medidas que vengan después.

import pandas as pd

URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'

v = pd.read_csv(URL)
print('filas:', len(v), '| duplicadas enteras:', v.duplicated().sum())

v = v.drop_duplicates()
print('quedan:', len(v))
filas: 3037 | duplicadas enteras: 37
quedan: 3000

37 filas idénticas. No es que se parezcan: son la misma venta escrita dos veces, con el mismo identificador y todo.

Dos: las cuatro Limas.

print(v['ciudad'].value_counts())
ciudad
Arequipa    546
Chiclayo    510
Piura       506
Cusco       496
Trujillo    471
lima        123
Líma        118
Lima        118
LIMA        112
Name: count, dtype: int64

Ahí está la trampa entera: lima, Líma, Lima y LIMA. Para ti son la misma ciudad. Para pandas son cuatro textos distintos, y ninguno se parece más al otro que a Piura.

Se arregla en una línea encadenada:

v['ciudad'] = (v['ciudad'].str.strip().str.lower()
               .str.normalize('NFKD')
               .str.encode('ascii', 'ignore').str.decode('utf-8'))

print(v['ciudad'].value_counts())
ciudad
arequipa    546
chiclayo    510
piura       506
cusco       496
trujillo    471
lima        471
Name: count, dtype: int64

Seis ciudades 🎉 Y Lima pasa de aparentar 123 ventas a tener 471, que la pone empatada con Trujillo.

Lo que hace cada trozo, por si te lo preguntas: strip quita espacios de los bordes, lower pasa todo a minúscula, y ese normalize('NFKD') con el encode/decode es el truco que convierte í en i. Descompone la letra acentuada en letra más tilde y luego tira lo que no sea ASCII.

Tres: los números que llegaron como texto.

print(v.dtypes.head(9))
id_venta      int64
fecha           str
cliente_id      str
ciudad          str
segmento        str
canal           str
categoria       str
unidades      int64
monto           str
dtype: object

monto es str, o sea texto. Y ya sabemos por qué: trae comas decimales al estilo peruano, y pandas ante la duda no toca nada.

v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))
print(v['monto'].dtype, '| media: %.2f' % v['monto'].mean())
float64 | media: 803.76

Los cuatro tipos, con lo que puedes hacer con cada uno

Ahora la parte que te va a servir para el resto del libro. Cada columna es de uno de estos cuatro tipos 👇

TipoQué esEn este archivoQué puedes calcular
Nominal Categorías sin orden ciudad, segmento, canal, categoria Frecuencias y moda. Nada más.
Ordinal Categorías con orden, pero sin distancia fija satisfaccion (1 a 5) Mediana, percentiles, moda
Discreta Números que se cuentan, sin partes unidades Todo, y la media puede salir con decimales
Continua Cualquier valor dentro de un rango monto, descuento Todo: media, desviación, correlación

La frontera que más se discute es la de ordinal. Satisfacción va de 1 a 5 y tiene orden clarísimo: 5 es mejor que 4. Pero la distancia no es fija. Pasar de 1 a 2 no es lo mismo, para el cliente, que pasar de 4 a 5. Y como no lo es, sumar y dividir es discutible.

Yo la uso así, y te lo digo como criterio práctico y no como ley: la mediana de satisfacción es la medida honesta; la media se puede reportar al lado, pero no se pelea por dos décimas 🤷

Ahora el error de verdad

Pídele a pandas la media de una columna de texto:

v['ciudad'].mean()
TypeError: Cannot perform reduction 'mean' with string dtype

Perfecto, ¿no? Te para en seco. Ese error es tu amigo.

El problema es el caso de al lado, que es el mismo disparate y no para nada:

print(v[['id_venta', 'unidades', 'monto', 'satisfaccion', 'compro']]
      .describe().round(2))
       id_venta  unidades    monto  satisfaccion   compro
count   3000.00   3000.00  3000.00       2769.00  3000.00
mean   11499.50     11.60   803.76          3.01     0.58
std      866.17      5.77   751.99          1.42     0.49
min    10000.00      1.00 -2497.72          1.00     0.00
25%    10749.75      7.00   252.69          2.00     0.00
50%    11499.50     12.00   533.63          3.00     1.00
75%    12249.25     15.00  1068.68          4.00     1.00
max    12999.00     30.00  4236.71          5.00     1.00

Mira la primera columna. La media de id_venta es 11499.50.

Eso es el número de factura promedio. No existe. No significa nada. No se puede hacer nada con él. Y ahí está, con dos decimales, tan formal como los demás 😅

id_venta es un identificador: son números por comodidad, pero funcionan como nombres. Es tan nominal como ciudad. La diferencia es que ciudad se escribe con letras y por eso pandas te frena, y este se escribe con dígitos y por eso pandas calcula.

La lección: que una cuenta salga no quiere decir que signifique algo. El único que sabe qué es cada columna eres tú.

De paso, en esa misma tabla hay dos cosas más que vale la pena que veas:

  • 💸 El mínimo de monto es -2497.72. Hay ventas negativas. Las miramos en el capítulo 6.
  • 🕳️ El count de satisfaccion es 2769 y no 3000. Faltan 231 valores, y la media se calculó ignorándolos sin decir ni pío.

Cómo se detecta el tipo cuando no conoces los datos

Truco de campo, y de los que más uso: cuenta cuántos valores distintos tiene cada columna 🔎

print(v.nunique().sort_values())
compro                      2
canal                       4
segmento                    4
categoria                   5
satisfaccion                5
ciudad                      6
unidades                   30
descuento                 251
fecha_ultima_compra       399
cliente_id                617
fecha                     937
monto_final_facturado    1723
monto                    2964
id_venta                 3000
dtype: int64

Léelo de arriba abajo y el archivo se te ordena solo:

  • 🔢 Lo de arriba, con 2 a 6 valores distintos, es categórico. Da igual que compro y satisfaccion estén escritos con números.
  • 📈 Lo de abajo, con cientos o miles de valores distintos, es continuo o identificador.
  • 🚩 Y hay una bandera roja evidente: id_venta tiene 3000 valores distintos en 3000 filas. Una columna con tantos valores distintos como filas es un identificador, siempre.

Una media que sí significa algo, y mucho

Para que no te quedes con que las medias de columnas 0/1 son sospechosas, mira este caso, que es justo al revés:

print('media de compro:  %.4f' % v['compro'].mean())
print('cuantos compraron: %d de %d' % (v['compro'].sum(), len(v)))
print('proporcion:       %.4f' % (v['compro'].sum() / len(v)))
media de compro:  0.5777
cuantos compraron: 1733 de 3000
proporcion:       0.5777

La media de una columna que solo tiene ceros y unos es la proporción. Exactamente el mismo número.

Y eso es utilísimo: cada vez que quieras un porcentaje de "cuántos cumplen X", conviertes a 0/1 y pides la media. Lo vamos a usar en todo el libro, y en el capítulo 11 esa proporción es la que se compara entre grupos.

O sea que compro es nominal, su media técnicamente no debería significar nada, y significa muchísimo. La regla no es mecánica: es que tienes que saber qué representa el número 🧠

Practica 💪

1. ¿Cuántos clientes hay de verdad?

El archivo tiene 3.000 ventas. ¿De cuántos clientes distintos? ¿Y cuántas ventas hace un cliente típico?

print('clientes distintos:', v['cliente_id'].nunique())
print('ventas por cliente: %.2f' % (len(v) / v['cliente_id'].nunique()))
print(v['cliente_id'].value_counts().describe().round(2))
clientes distintos: 617
ventas por cliente: 4.86
count    617.00
mean       4.86
std        2.19
min        1.00
25%        3.00
50%        5.00
75%        6.00
max       14.00
Name: count, dtype: float64

617 clientes y 3.000 ventas: 4,86 ventas por cliente.

Esto cambia cómo hay que leer el archivo entero, y es de las cosas que más se pasan por alto. Las 3.000 filas no son 3.000 clientes independientes: hay uno que aparece 14 veces.

Cuando el mismo cliente sale muchas veces, sus filas se parecen entre ellas más de lo normal, y eso hace que cualquier prueba estadística crea que tiene más información de la que tiene. Es una de las cosas que invalidan análisis y sale en el capítulo 14.

2. Caza las categóricas disfrazadas de números

Escribe algo que recorra las columnas numéricas y te avise de cuáles son sospechosas de ser categorías.

for col in v.select_dtypes('number').columns:
    distintos = v[col].nunique()
    aviso = '  <-- categorica disfrazada' if distintos <= 10 else ''
    print('%-24s %5d distintos%s' % (col, distintos, aviso))
id_venta                  3000 distintos
unidades                    30 distintos
monto                     2964 distintos
descuento                  251 distintos
satisfaccion                 5 distintos  <-- categorica disfrazada
compro                       2 distintos  <-- categorica disfrazada
monto_final_facturado     1723 distintos

Caza las dos que buscábamos. Y fíjate que unidades, con 30 valores, se queda fuera: es discreta de verdad, no una categoría.

El umbral de 10 es mío y es discutible, que quede claro. Sirve como primer filtro para mirar, no como sentencia. Lo que este código hace es ahorrarte abrir 14 columnas a mano 🙌

3. La media que ignora los huecos

La columna descuento tiene vacíos. Calcula su media, y luego calcúlala tratando los vacíos como cero. ¿Cuál está bien?

print('filas totales:      ', len(v))
print('descuentos con dato:', v['descuento'].count())
print('media ignorando huecos: %.4f' % v['descuento'].mean())
print('media con huecos = 0:   %.4f' % v['descuento'].fillna(0).mean())
filas totales:       3000
descuentos con dato: 2405
media ignorando huecos: 0.1252
media con huecos = 0:   0.1004

12,52% contra 10,04%. Una diferencia de dos puntos y medio, decidida enteramente por lo que supongas de 595 celdas vacías.

¿Cuál está bien? Depende de qué signifique el vacío, y eso no está en los datos. Si el sistema deja el campo en blanco cuando no hubo descuento, el vacío es un cero y la buena es 10,04%. Si el campo se llena siempre y esos 595 se perdieron al migrar, el vacío es un desconocido y la buena es 12,52%.

Lo que no puedes hacer es no decidir. Porque mean() decide por ti: ignora los nulos en silencio y te devuelve 12,52% sin avisarte de que se saltó el 20% de las filas.

Esto se pregunta a quien mantiene el sistema. Es la clase de pregunta que distingue a alguien que analiza datos de alguien que ejecuta celdas 😌

4. Fabrica una variable continua: la recencia

Con fecha y fecha_ultima_compra, calcula cuántos días hacía que ese cliente no compraba. Mira bien el resultado antes de darlo por bueno.

for col in ['fecha', 'fecha_ultima_compra']:
    f = pd.to_datetime(v[col], format='%Y-%m-%d', errors='coerce')
    falta = f.isna() & v[col].notna()
    f[falta] = pd.to_datetime(v.loc[falta, col], format='%d/%m/%Y',
                              errors='coerce')
    v[col] = f

dias = (v['fecha'] - v['fecha_ultima_compra']).dt.days
print(dias.describe().round(2))
print('negativos:', (dias < 0).sum())
count    2456.00
mean       72.82
std       190.33
min      -385.00
25%       -65.00
50%        69.00
75%       213.25
max       522.00
dtype: float64
negativos: 904

Aquí hay que parar 🛑

904 valores negativos. O sea 904 ventas donde la "última compra" del cliente ocurrió después de la venta que estamos mirando.

Eso no es un dato raro, es un dato imposible tal como lo estamos leyendo. La explicación más probable es que la columna no sea "la compra anterior a esta" sino "la última compra registrada del cliente hasta hoy", que es un dato del cliente y no de la venta. Con ese significado los negativos son normales: la venta que miramos es vieja y el cliente volvió después.

La conclusión práctica es dura y es la correcta: esta columna no sirve como recencia y hay que preguntar antes de usarla. Si la metes en un análisis tal cual, la mitad de tus filas dice que el cliente compró hace -65 días.

Lo que hicimos aquí, mirar el describe() de una variable recién creada, es lo que evitó publicarlo. Cuesta diez segundos y salva informes 🙏

5. Ordinal: ¿media o mediana?

Compara la satisfacción entre segmentos con las dos medidas. ¿Cuál cuenta mejor lo que pasa?

print(v.groupby('segmento')['satisfaccion']
      .agg(['mean', 'median', 'count']).round(4))
              mean  median  count
segmento
Bodega      2.9603     3.0    655
Horeca      3.0332     3.0    692
Mayorista   2.9927     3.0    684
Minimarket  3.0474     3.0    738

Las medias van de 2,96 a 3,05. Las medianas son todas 3.

Y las dos están contando lo mismo: aquí no pasa nada. La satisfacción no distingue segmentos.

Fíjate en lo fácil que sería hacer el ridículo con esto. Con las medias en la mano, alguien escribe "Minimarket es nuestro segmento más satisfecho, con 3,05 frente al 2,96 de Bodega". Suena a hallazgo. Son nueve centésimas en una escala de 1 a 5, con setecientas filas por grupo.

La mediana, al ser toda 3, te lo deja más difícil de contar mal. Por eso me gusta para ordinales: no te deja fingir precisión que no tienes 🎯

En el capítulo 12 le ponemos número a esto y sale que la diferencia es minúscula incluso comparada con lo que varía la satisfacción dentro de un mismo segmento.

6. La columna que solo existe si la venta se cerró

Saca la media de monto_final_facturado sobre todas las filas y luego solo sobre las que compraron. Explica la diferencia.

print('media sobre las 3000 filas:      %.2f' % v['monto_final_facturado'].mean())
print('media solo sobre los que compraron: %.2f'
      % v.loc[v['compro'] == 1, 'monto_final_facturado'].mean())
print('cuantos ceros:', (v['monto_final_facturado'] == 0).sum())
media sobre las 3000 filas:      481.26
media solo sobre los que compraron: 833.10
cuantos ceros: 1267

481 contra 833. Casi el doble, y las dos son "la media de la misma columna" 🫠

Lo que pasa es que 1.267 filas tienen un cero, y esos ceros no son ventas de cero soles: son ventas que no ocurrieron. La columna solo se llena cuando compro vale 1.

El número que un negocio quiere oír, el ticket promedio, es el de 833,10, y se calcula solo sobre las ventas cerradas. El 481,26 no es el ticket de nada: es una mezcla de tickets y de no-ventas.

Y ojo con esta columna, porque tiene otra trampa peor que la usamos en el libro de machine learning: como solo existe si la venta se cerró, meterla en un modelo que predice si la venta se va a cerrar es hacer trampa. Se llama fuga de información y está contada en el capítulo de fuga del libro de ML.

7. La limpieza completa, en una función

Junta todo lo del capítulo en algo que puedas pegar al inicio de cualquier cuaderno.

def carga_limpia(url):
    """Lee el archivo y deja cada columna en su tipo."""
    v = pd.read_csv(url).drop_duplicates()

    v['ciudad'] = (v['ciudad'].str.strip().str.lower()
                   .str.normalize('NFKD')
                   .str.encode('ascii', 'ignore').str.decode('utf-8'))

    v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))

    for col in ['fecha', 'fecha_ultima_compra']:
        f = pd.to_datetime(v[col], format='%Y-%m-%d', errors='coerce')
        falta = f.isna() & v[col].notna()
        f[falta] = pd.to_datetime(v.loc[falta, col], format='%d/%m/%Y',
                                  errors='coerce')
        v[col] = f

    return v

limpio = carga_limpia(URL)
print(limpio.shape)
print(limpio['ciudad'].nunique(), 'ciudades |',
      limpio['monto'].dtype, '| fechas:', limpio['fecha'].dtype)
(3000, 14)
6 ciudades | float64 | fechas: datetime64[us]

Esta función es la que abre todos los capítulos que vienen. Cópiala 📌

Fíjate en ese falta = f.isna() & v[col].notna(): solo reintenta las fechas que tenían texto y no se pudieron convertir. Sin la segunda condición intentaría rellenar también los huecos de verdad, y esos tienen que seguir siendo huecos 🕳️

Comprueba que lo tienes

describe() te devuelve que la media de id_venta es 11499,50. ¿Qué haces?

  • Nada, es un identificador y esa media no significa nada
  • La reporto como el número de factura promedio
  • Reviso si hay un error en los datos
  • La uso para detectar facturas fuera de rango

Lo que te llevas

  • 🧹 Duplicados primero, textos normalizados después, tipos al final. En ese orden, porque cada paso ensucia al siguiente si se hace al revés.
  • 🏙️ Cuatro formas de escribir Lima son cuatro ciudades para el software. strip, lower y normalize lo arreglan en una línea.
  • 📊 Nominal solo admite frecuencias y moda; ordinal, mediana; discreta y continua, todo.
  • 🤖 El software no conoce esa diferencia. describe() te dará la media de un número de factura y se quedará tan tranquilo.
  • 🔎 nunique() es el mejor detector de tipos que hay. Pocos valores distintos, categoría; tantos valores como filas, identificador.
  • 0️⃣ La media de una columna de ceros y unos es la proporción, y esa sí significa muchísimo.
  • 🕳️ mean() ignora los nulos sin avisar. Aquí eran 595 celdas y movían el descuento medio dos puntos y medio.

Qué viene ahora

En el capítulo 3 nos metemos con la medida más usada y peor usada del mundo: la media. Con un caso donde la media dice 803 soles, la mediana dice 533 y la diferencia entre las dos es una decisión de negocio 💰

¿Tienes alguna duda o consulta?