Capítulo 13 de 16 12 secciones 13 min

Sacar datos de un texto escrito por personas

Expresiones regulares sobre 2.440 comentarios de clientes, con sus tildes que faltan y sus emojis.

Hasta aquí el libro limpió números y en una empresa peruana lo que más llega es texto escrito por personas. Yo saco de ahí números de pedido y montos con expresiones regulares, que son cuatro símbolos y se aprenden en una tarde. Sobre estos 2.440 comentarios hay 311 con espacios de sobra, 184 gritados en mayúsculas y 288 con un correo o un teléfono que hay que tapar 🔎

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 😤

De un comentario de cliente salen los tres usos de una expresión regular: buscar con re.search, extraer con los paréntesis para quedarse con el monto, y reemplazar para tapar los 288 correos y teléfonos.
Son tres usos y uno solo se lleva la fama. El de reemplazar es el que más veces me ha tocado, porque es el que anonimiza.

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ímboloQué significaEjemplo
\dUn dígito\d\d\d\d encuentra 4471
\wUna letra, dígito o guion bajo\w+ encuentra una palabra
\sUn 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 parteLo 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 🧽

  • ✂️ strip quita los espacios de los extremos, los 311 de arriba.
  • 🔡 lower junta 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 esUsa
HTML de una páginaUn lector de HTML de verdad. El HTML se anida y las expresiones regulares no saben de eso
Un CSV o un JSONpandas 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 verdadUna librería. El patrón completo del estándar tiene cientos de caracteres
Entender qué dice el mensajeNada 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 if para cada forma

Lo que te llevas

  • 🔎 \d, \w, \s, + y los paréntesis resuelven casi todo.
  • 🅁 La r va siempre delante del patrón.
  • 📌 Los paréntesis dicen qué parte te llevas, y str.extract lo 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! 🌸

¿Tienes alguna duda o consulta?