Doce capítulos limpiando números. Y en una empresa peruana lo que más llega no son números: son mensajes escritos por personas 💬
Reclamos por WhatsApp, correos de clientes, comentarios de una encuesta. Ahí dentro está el número de pedido, el monto y el motivo, y hay que sacarlos.
Antes de empezar, una pregunta: ¿cómo le explicarías a Python que busque "un número de cuatro cifras que venga después de la palabra pedido"? Con lo del capítulo 3 no se puede. Con lo de este capítulo son doce caracteres 🔎
Mira lo que llega de verdad
import re import pandas as pd URL = 'https://missyera.com/static/datasets/comentarios-miss-yera.csv' c = pd.read_csv(URL) print('comentarios:', len(c)) for texto in c['comentario'].head(4): print(repr(texto))
comentarios: 2440 'me pueden reenviar la factura por favor,\n no me llegó' 'me estan cobrando S/ 1301.96 y yo no pedi eso, quiero que lo anulen YA' 'buenas, consulta si hay descuento por comprar 43 unidades porfavor responder 🙂' ' buenos dias, quiero felicitar a la persona que me atendio, muy amable saludos '
Fíjate en que uso repr y no print a secas, que es un
truco del capítulo 7: así se ven los saltos de línea y
los espacios del principio, que con print normal son invisibles y
son justo la mitad del problema 👀
print('con espacios de sobra :', int((c['comentario'] != c['comentario'].str.strip()).sum())) print('con salto de linea :', int(c['comentario'].str.contains('\n').sum())) print('en MAYUSCULAS enteras :', int((c['comentario'].str.strip() == c['comentario'].str.strip().str.upper()).sum())) print('con emoji :', int(c['comentario'].str.contains( r'[\U0001F300-\U0001FAFF]', regex=True).sum()))
con espacios de sobra : 311 con salto de linea : 121 en MAYUSCULAS enteras : 184 con emoji : 718
311 con espacios de sobra y 718 con emoji. Y las 184 en mayúsculas no son un defecto: son gente enojada, o sea información 😤
Lo mínimo que hay que saber
Una expresión regular es un patrón: una forma de describir "un texto que se parece a esto". Se escribe con símbolos y hay como veinte, de los cuales estos cinco resuelven casi todo:
| Símbolo | Qué significa | Ejemplo |
|---|---|---|
\d | Un dígito | \d\d\d\d encuentra 4471 |
\w | Una letra, dígito o guion bajo | \w+ encuentra una palabra |
\s | Un espacio, tabulación o salto de línea | \s+ encuentra varios seguidos |
+ | Uno o más de lo anterior | \d+ encuentra 12 y también 4471 |
( ) | Guárdame esta parte | Lo veremos en un momento y es lo bueno |
texto = 'ya van 12 dias y el pedido 4471 NO LLEGA, esto es el colmo' print(re.search(r'\d+', texto)) print(re.findall(r'\d+', texto))
<re.Match object; span=(7, 9), match='12'> ['12', '4471']
Dos funciones y ya sabes la mitad. re.search devuelve
el primero con su posición, y re.findall devuelve
todos en una lista.
Y ojo con la r de r'\d+', que no es decoración. Sin
ella Python interpreta la barra invertida antes de que la expresión regular la
vea, y el patrón deja de significar lo mismo. Las expresiones regulares
van siempre con r delante, siempre, y así te ahorras un
error que cuesta media tarde encontrar 🧯
Los paréntesis, que es donde está el trabajo
Encontrar el número no basta: había dos y solo uno es el pedido. Los paréntesis dicen qué parte me quiero llevar:
m = re.search(r'pedido\s+(\d{4})', texto) print('el trozo entero :', m.group(0)) print('solo el numero :', m.group(1)) print('donde empieza :', m.start())
el trozo entero : pedido 4471 solo el numero : 4471 donde empieza : 20
Se lee de izquierda a derecha, como una frase: la palabra "pedido", uno o más espacios, y cuatro dígitos que me guardo 📌
El {4} dice exactamente cuántos, y es más seguro que
+: con + también valdría un número de nueve cifras, que
en estos datos sería otra cosa.
Y group(0) es todo lo que coincidió, group(1) es el
primer paréntesis. Ese desfase de uno confunde al principio y después se vuelve
automático.
Sobre las 2.440 filas a la vez
pandas trae las expresiones regulares metidas en las columnas de texto, así que no hace falta ningún bucle:
c['pedido'] = c['comentario'].str.extract(r'pedido\s+(\d{4})') c['monto'] = c['comentario'].str.extract(r'S/\s*([\d.]+)') print('comentarios con numero de pedido:', int(c['pedido'].notna().sum())) print('comentarios con un monto :', int(c['monto'].notna().sum())) for pedido, texto in c.loc[c['pedido'].notna(), ['pedido', 'comentario']].head(3).values: print(f' {pedido} <- {texto.strip()[:60]}')
comentarios con numero de pedido: 265 comentarios con un monto : 352 1945 <- llego la factura pero no el pedido 1945 1540 <- ya van 28 dias y el pedido 1540 NO LLEGA, esto es el colmo 🤬 4924 <- necesito la factura del pedido 4924 a mi correo cliente46@ho
De 2.440 mensajes sueltos salieron 265 números de pedido y 352 montos, en dos líneas 🎉
Eso ya es una tabla que se puede cruzar con la base de pedidos del libro de SQL desde cero y contestar cuántos reclamos tiene cada pedido 🗃️
str.extract devuelve el primer paréntesis de cada fila, y
NaN donde no encontró nada. Que devuelva nulo y no reviente es la
decisión correcta: los otros 2.175 comentarios simplemente no traen pedido.
Anonimizar, que es lo que te van a pedir
Y aquí viene el uso que más veces me ha tocado: quitar los datos personales antes de que el texto salga a ningún lado. Si vas a mandar estos comentarios a un modelo, a un proveedor o a un cuaderno compartido, esto no es opcional 🔒
CORREO = r'[\w.+-]+@[\w-]+\.[\w.]+' TELEFONO = r'\b9\d{8}\b' c['anonimo'] = (c['comentario'] .str.replace(CORREO, '[correo]', regex=True) .str.replace(TELEFONO, '[telefono]', regex=True)) tocados = int((c['anonimo'] != c['comentario']).sum()) print('comentarios anonimizados:', tocados) for antes, despues in zip(c.loc[c['anonimo'] != c['comentario'], 'comentario'].head(2), c.loc[c['anonimo'] != c['comentario'], 'anonimo'].head(2)): print(' antes :', antes.strip()) print(' despues:', despues.strip())
comentarios anonimizados: 288 antes : me pueden pasar la lista de precios actualizada a cliente165@hotmail.com? 💛💛 despues: me pueden pasar la lista de precios actualizada a [correo]? 💛💛 antes : me pueden pasar la lista de precios actualizada a cliente240@hotmail.com? 🙏 despues: me pueden pasar la lista de precios actualizada a [correo]? 🙏
288 comentarios tapados 🔒
El \b del teléfono es una frontera de palabra, y
está ahí por una razón concreta: sin él, un número de trece cifras también
coincidiría por dentro. \b exige que ahí empiece y termine.
Una advertencia honesta, porque esto es de las cosas donde equivocarse cuesta caro: una expresión regular anonimiza lo que sabías buscar. Si alguien escribió su teléfono con guiones o su DNI sin avisar, sigue ahí. Esto reduce el riesgo, no lo elimina, y hay que decirlo cuando entregas 🚩
Normalizar, o cuatro facturas que eran una
El otro trabajo grande del texto es que la misma palabra viene escrita de varias formas. Es lo mismo que le pasaba a la ciudad en el capítulo 12, y se arregla igual:
import unicodedata def normaliza(s): s = s.strip().lower() s = re.sub(r'\s+', ' ', s) s = unicodedata.normalize('NFKD', s) s = s.encode('ascii', 'ignore').decode('utf-8') return s c['limpio'] = c['comentario'].map(normaliza) print('antes :', repr(c['comentario'].iloc[3])) print('despues:', repr(c['limpio'].iloc[3])) print('formas distintas de "factura" antes :', c['comentario'].str.findall(r'(?i)factura').explode().dropna().nunique()) print('formas distintas de "factura" despues:', c['limpio'].str.findall(r'factura').explode().dropna().nunique())
antes : ' buenos dias, quiero felicitar a la persona que me atendio, muy amable saludos ' despues: 'buenos dias, quiero felicitar a la persona que me atendio, muy amable saludos' formas distintas de "factura" antes : 2 formas distintas de "factura" despues: 1
Cuatro líneas y cada una arregla una cosa distinta 🧽
- ✂️
stripquita los espacios de los extremos, los 311 de arriba. - 🔡
lowerjunta FACTURA con factura. - 🌬️
re.sub(r'\s+', ' ', s)convierte cualquier racha de espacios o saltos de línea en un espacio solo. Es la línea que más veces vas a reutilizar. - 🇵🇪 Y las dos últimas quitan las tildes, para que "atendió" y "atendio" sean la misma palabra.
Lo de las tildes es una decisión y no una obviedad: en un
buscador ayuda, y si lo que vas a hacer es mostrarle el texto a una persona, se
lo estás rompiendo. Guarda siempre el original al lado, que es para lo que existe
la columna limpio aparte 💛
El símbolo que se come todo
Y ahora el error clásico, el que le pasa a todo el mundo la primera semana:
codicioso = re.findall(r'".+"', 'el cliente dijo "no llego" y despues "no responden"') perezoso = re.findall(r'".+?"', 'el cliente dijo "no llego" y despues "no responden"') print('con .+ :', codicioso) print('con .+? :', perezoso)
con .+ : ['"no llego" y despues "no responden"'] con .+? : ['"no llego"', '"no responden"']
Con .+ sale una sola cosa gigante 😳
Y no es un error de la librería: .+ significa "todo lo que
puedas", y lo que más puede es llegar hasta la última comilla. Se llama
codicioso.
La interrogación lo vuelve perezoso: "todo lo que puedas, pero para en cuanto se pueda". Esa interrogación de más es la diferencia entre dos resultados y uno.
Una expresión regular hace lo que le pediste, no lo que querías. Y casi siempre pediste más de lo que creías.
El error que sale cuando el patrón está mal escrito
Y este lo vas a ver el primer día, porque los paréntesis se olvidan solos:
re.compile(r'(pedido \d{4}')
error: missing ), unterminated subpattern at position 0
unterminated subpattern, o sea que abriste un paréntesis y no lo
cerraste. La position 0 te dice en qué carácter del patrón está el
problema, no de tu archivo 📍
Es de los errores agradecidos: revienta enseguida y te dice dónde. El peligroso es el otro, el del patrón bien escrito que encuentra menos de lo que crees y no se queja 🚩
La negación, otra vez
Con el texto ya normalizado se puede contar de verdad. Y aquí sale lo mismo que en el libro de deep learning desde cero, con otro disfraz:
print(c[c['limpio'].str.contains(r'\bno\s+llego\b')].shape[0], 'dicen que no llego') print(c[c['limpio'].str.contains(r'\bllego\b')].shape[0], 'dicen llego, contando los anteriores')
55 dicen que no llego 383 dicen llego, contando los anteriores
Si cuentas la palabra "llegó" a secas te salen 383, y 55 de esos dicen justo lo contrario 🙃
Ese es el techo de contar palabras, y no se arregla con más expresiones regulares: se arregla mirando el orden, que es de lo que va el capítulo de recurrentes del otro libro. Aquí lo que hay que llevarse es saber que el techo existe.
Cuándo NO usar una expresión regular
| Si lo que tienes es | Usa |
|---|---|
| HTML de una página | Un lector de HTML de verdad. El HTML se anida y las expresiones regulares no saben de eso |
| Un CSV o un JSON | pandas y json, que ya saben la estructura |
| Buscar un texto fijo, sin patrón | 'palabra' in texto, que es más rápido y se lee |
| Validar un correo de verdad | Una librería. El patrón completo del estándar tiene cientos de caracteres |
| Entender qué dice el mensaje | Nada de esto. Eso es procesamiento de lenguaje y empieza en el otro libro |
Y la regla que uso yo: si el patrón no me cabe en un renglón, casi siempre es que la herramienta está mal elegida 📏
La trampa
Un equipo saca los montos de los reclamos para sumar cuánto se está reclamando en total. Corre sin errores y el número que sale se lleva a gerencia.
montos = c['comentario'].str.extract(r'S/\s*([\d.]+)')[0] total = pd.to_numeric(montos).sum() print(f'se reclaman S/ {total:,.2f} en total')
Qué está mal
Dos cosas, y ninguna da error. La primera: str.extract se queda con el primero de cada comentario, así que un mensaje que mencione dos montos aporta uno solo y el total sale corto. La segunda y peor: el patrón [\d.]+ acepta cualquier mezcla de dígitos y puntos, así que en un texto donde el monto va al final de la frase se lleva también el punto final, y "S/ 250." se convierte en un número que pandas puede leer distinto de lo que crees. Se arregla pidiendo la forma exacta, S/\s*(\d+(?:\.\d{2})?), que es "dígitos y opcionalmente un punto con exactamente dos decimales". Y la comprobación que hay que hacer siempre antes de sumar nada: mira el máximo y el mínimo de lo que extrajiste, porque un total inflado no se nota y un máximo absurdo sí 🚩
Ejercicios
Seis, y el 4 es el que más se parece a un encargo de verdad. Intenta antes de abrir 💛
1. Los que gritan
Cuenta cuántos comentarios están escritos enteros en mayúsculas y mira si están más en un tema que en otro.
c['grita'] = c['comentario'].str.strip().str.isupper() print('gritan:', int(c['grita'].sum())) print(c.groupby('tema')['grita'].mean().round(4).to_string())
Escribir en mayúsculas es una señal, no un defecto de datos. Si la proporción cambia mucho entre temas, ya tienes un indicador de urgencia sin ningún modelo.
2. Contar los emojis
Saca cuántos emojis tiene cada comentario y compara la media entre los molestos y los tranquilos.
c['emojis'] = c['comentario'].str.count(r'[\U0001F300-\U0001FAFF]') print(c.groupby('molesto')['emojis'].mean().round(3).to_string())
El rango entre llaves es un trozo de la tabla Unicode. No hay que memorizarlo: hay que saber que los emojis son caracteres como cualquier otro y que se pueden buscar por rango.
3. Las horas que nadie te dijo que estaban
Busca patrones tipo 14:30 en los comentarios.
Escribe el patrón antes de correrlo.
horas = c['comentario'].str.extract(r'\b(\d{1,2}:\d{2})\b') print('comentarios con hora:', int(horas[0].notna().sum()))
Puede que salgan cero, y eso también es un resultado: comprobar que algo NO está es tan útil como encontrarlo, y te ahorra escribir código para un caso que no existe.
4. El informe de una semana
Junta todo el capítulo: normaliza, extrae el pedido, anonimiza, y saca una tabla de cuántos reclamos hay por tema y ciudad, solo de los molestos.
molestos = c[c['molesto'] == 1] tabla = molestos.pivot_table(index='ciudad', columns='tema', values='id_comentario', aggfunc='count') print(tabla.fillna(0).astype(int).to_string())
Esto es lo que se entrega, y fíjate en que el código de expresiones regulares es la parte corta. Lo largo es decidir qué mirar, igual que en el capítulo 15.
5. Rómpelo a propósito
Quita la r de delante de un patrón con
\d y mira qué pasa.
print(re.findall('\\d+', 'el pedido 4471')) # con r delante, funciona print(re.findall('\d+', 'el pedido 4471')) # sin r, Python avisa
En las versiones recientes Python lanza un aviso porque \d no es
una secuencia de escape válida de una cadena normal. Hoy avisa, y en algún
momento va a ser un error. Por eso la r va siempre.
6. Tu propio buzón
Sin dataset. Exporta veinte mensajes de un WhatsApp de
trabajo tuyo y pásales normaliza y el anonimizador.
Es el ejercicio que más enseña de los seis, porque tus datos van a tener una suciedad que este archivo no tiene y vas a descubrir sola qué patrón te falta. Ojo con una cosa: si son mensajes de clientes de verdad, anonimízalos antes de pegarlos en ningún sitio 🔒
Comprueba que lo tienes
Te piden sacar el número de guía de remisión de 3.000 correos. Vienen como "GR-0012345", pero algunos escriben "GR 0012345" y otros "gr0012345". ¿Qué patrón escribes?
(?i)gr[-\s]?(\d{7})GR-(\d{7})(?i)gr.*(\d+)- Un bucle con
ifpara cada forma
Lo que te llevas
- 🔎
\d,\w,\s,+y los paréntesis resuelven casi todo. - 🅁 La
rva siempre delante del patrón. - 📌 Los paréntesis dicen qué parte te llevas, y
str.extractlo hace sobre la columna entera. - 🔒 Anonimizar es dos
str.replace, y solo tapa lo que supiste buscar. - 🍽️
.+se come todo..+?para en cuanto puede. - 🚫 Y si el patrón no cabe en un renglón, revisa si la herramienta es la correcta.
Con esto ya puedes limpiar lo que llega en texto. Lo que viene después, entender qué dice, es otro oficio y empieza en el libro de deep learning desde cero 🧠
Que tengas lindo día! 🌸