Expresiones Regulares

S2: La Navaja Suiza del NLP

Prof. Francisco Suárez

Universidad Católica Boliviana

2026-03-17

Agenda de Hoy

Primera Parte

  1. 🎯 ¿Qué son las expresiones regulares?
  2. 📝 Sintaxis básica
  3. 🔧 Metacaracteres y cuantificadores

Segunda Parte

  1. 🧩 Grupos y capturas
  2. 🐍 Regex en Python
  3. 💡 Aplicaciones en NLP

¿Qué son las Expresiones Regulares?

Definición

“Algunas personas, cuando se enfrentan a un problema, piensan: ‘Ya sé, usaré expresiones regulares.’ Ahora tienen dos problemas.”
— Jamie Zawinski

Expresión Regular (Regex)

Una expresión regular es una secuencia de caracteres que define un patrón de búsqueda. Se utiliza para encontrar, extraer o reemplazar texto que coincida con dicho patrón.

¿Por qué aprender Regex?

Casos de uso comunes:

  • 🔍 Buscar patrones en texto
  • ✅ Validar formatos (email, teléfono)
  • 🧹 Limpiar y normalizar datos
  • 📊 Extraer información estructurada
  • 🔄 Reemplazar texto masivamente

En NLP específicamente:

  • Tokenización personalizada
  • Extracción de entidades simples
  • Preprocesamiento de corpus
  • Normalización de texto
  • Filtrado de ruido

Historia Breve

Code
timeline
    title Historia de las Expresiones Regulares
    1951 : Stephen Kleene
         : Álgebra de conjuntos regulares
    1968 : Ken Thompson
         : Implementación en editor de texto
    1970s : grep, sed, awk
          : Herramientas Unix
    1980s : Perl
          : "Practical Extraction and Report Language"
    1990s : PCRE
          : Perl Compatible Regular Expressions
    2000s : Integración universal
          : Python, Java, JavaScript, etc.

timeline
    title Historia de las Expresiones Regulares
    1951 : Stephen Kleene
         : Álgebra de conjuntos regulares
    1968 : Ken Thompson
         : Implementación en editor de texto
    1970s : grep, sed, awk
          : Herramientas Unix
    1980s : Perl
          : "Practical Extraction and Report Language"
    1990s : PCRE
          : Perl Compatible Regular Expressions
    2000s : Integración universal
          : Python, Java, JavaScript, etc.

Sintaxis Básica

Caracteres Literales

El caso más simple: buscar texto exacto.

import re

texto = "El gato y el perro juegan en el jardín"

# Buscar la palabra "gato"
resultado = re.search(r"gato", texto)
print(f"Texto: '{texto}'")
print(f"Patrón: 'gato'")
print(f"Encontrado: '{resultado.group()}' en posición {resultado.start()}")
Texto: 'El gato y el perro juegan en el jardín'
Patrón: 'gato'
Encontrado: 'gato' en posición 3

El prefijo r

En Python, usamos r"patrón" (raw string) para evitar problemas con caracteres de escape como \n o \t.

Metacaracteres Especiales

Metacaracter Significado Ejemplo Coincide con
. Cualquier carácter (excepto \n) c.t cat, cut, c9t
^ Inicio de línea/cadena ^Hola “Hola mundo”
$ Fin de línea/cadena mundo$ “Hola mundo”
\d Dígito [0-9] \d\d 42, 99
\w Palabra [a-zA-Z0-9_] \w+ palabra_123
\s Espacio en blanco \s+ ” “,”, “”
\b Límite de palabra \bcat\b “cat” pero no “category”

Ejemplo: Metacaracteres

import re

texto = "Mi teléfono es 591-78945612 y mi código es A123"

# \d+ encuentra secuencias de dígitos
digitos = re.findall(r"\d+", texto)
print(f"Dígitos encontrados: {digitos}")

# \w+ encuentra secuencias de caracteres de palabra
palabras = re.findall(r"\w+", texto)
print(f"Palabras encontradas: {palabras}")

# Buscar patrón específico: código alfanumérico
codigo = re.search(r"[A-Z]\d{3}", texto)
print(f"Código encontrado: {codigo.group()}")
Dígitos encontrados: ['591', '78945612', '123']
Palabras encontradas: ['Mi', 'teléfono', 'es', '591', '78945612', 'y', 'mi', 'código', 'es', 'A123']
Código encontrado: A123

Clases de Caracteres [ ]

Definen un conjunto de caracteres válidos para una posición.

import re

texto = "bag, big, bog, bug, beg, b9g"

# [aeiou] = cualquier vocal
patron_vocal = re.findall(r"b[aeiou]g", texto)
print(f"b[aeiou]g → {patron_vocal}")

# [a-z] = cualquier letra minúscula
patron_letra = re.findall(r"b[a-z]g", texto)
print(f"b[a-z]g → {patron_letra}")

# [^aeiou] = cualquier cosa EXCEPTO vocales (negación)
patron_no_vocal = re.findall(r"b[^aeiou]g", texto)
print(f"b[^aeiou]g → {patron_no_vocal}")
b[aeiou]g → ['bag', 'big', 'bog', 'bug', 'beg']
b[a-z]g → ['bag', 'big', 'bog', 'bug', 'beg']
b[^aeiou]g → ['b9g']

Resumen: Clases de Caracteres

Clase Equivalente Descripción
[abc] - a, b, o c
[^abc] - Cualquiera excepto a, b, c
[a-z] - Cualquier minúscula
[A-Z] - Cualquier mayúscula
[0-9] \d Cualquier dígito
[a-zA-Z0-9_] \w Carácter de “palabra”
[ \t\n\r] \s Espacio en blanco

Cuantificadores

¿Cuántas veces?

Los cuantificadores especifican cuántas veces debe aparecer el elemento anterior.

Cuantificador Significado Ejemplo
* 0 o más veces ab*c → ac, abc, abbc
+ 1 o más veces ab+c → abc, abbc (no ac)
? 0 o 1 vez colou?r → color, colour
{n} Exactamente n veces \d{3} → 123, 456
{n,} n o más veces \d{2,} → 12, 123, 1234
{n,m} Entre n y m veces \d{2,4} → 12, 123, 1234

Ejemplo: Cuantificadores

import re

texto = "Los números son: 1, 12, 123, 1234, 12345"

# Exactamente 3 dígitos
tres_digitos = re.findall(r"\b\d{3}\b", texto)
print(f"Exactamente 3 dígitos: {tres_digitos}")

# 2 a 4 dígitos
rango = re.findall(r"\b\d{2,4}\b", texto)
print(f"Entre 2 y 4 dígitos: {rango}")

# 1 o más dígitos (greedy por defecto)
todos = re.findall(r"\d+", texto)
print(f"Uno o más dígitos: {todos}")
Exactamente 3 dígitos: ['123']
Entre 2 y 4 dígitos: ['12', '123', '1234']
Uno o más dígitos: ['1', '12', '123', '1234', '12345']

Greedy vs. Lazy

Greedy (Codicioso) 🐷

Por defecto, los cuantificadores son greedy: intentan capturar el máximo posible.

import re

html = "<p>Hola</p><p>Mundo</p>"

# Greedy: captura todo
greedy = re.findall(r"<p>.*</p>", html)
print(f"Greedy: {greedy}")
Greedy: ['<p>Hola</p><p>Mundo</p>']

Lazy (Perezoso) 🦥

Agregando ? después del cuantificador, se vuelve lazy: captura el mínimo.

import re

html = "<p>Hola</p><p>Mundo</p>"

# Lazy: captura lo mínimo
lazy = re.findall(r"<p>.*?</p>", html)
print(f"Lazy: {lazy}")
Lazy: ['<p>Hola</p>', '<p>Mundo</p>']

Grupos y Capturas

Grupos con Paréntesis ( )

Los paréntesis crean grupos de captura que permiten:

  1. Agrupar elementos para aplicar cuantificadores
  2. Extraer partes específicas del match
  3. Reutilizar el contenido capturado
import re

texto = "Mi email es juan.perez@ucb.edu.bo y también tengo maria@gmail.com"

# Capturar partes del email
patron = r"(\w+[\w.]*\w+)@(\w+\.\w+(?:\.\w+)?)"
emails = re.findall(patron, texto)

for usuario, dominio in emails:
    print(f"Usuario: {usuario}, Dominio: {dominio}")
Usuario: juan.perez, Dominio: ucb.edu.bo
Usuario: maria, Dominio: gmail.com

Grupos con Nombre

Podemos nombrar los grupos para mayor claridad con (?P<nombre>...):

import re

fecha = "Hoy es 04/02/2026 y mañana será 05/02/2026"

# Grupos nombrados
patron = r"(?P<dia>\d{2})/(?P<mes>\d{2})/(?P<año>\d{4})"
matches = re.finditer(patron, fecha)

for match in matches:
    print(f"Fecha: {match.group()}")
    print(f"  Día: {match.group('dia')}")
    print(f"  Mes: {match.group('mes')}")
    print(f"  Año: {match.group('año')}")
    print()
Fecha: 04/02/2026
  Día: 04
  Mes: 02
  Año: 2026

Fecha: 05/02/2026
  Día: 05
  Mes: 02
  Año: 2026

Grupos No Capturadores

A veces necesitamos agrupar sin capturar. Usamos (?:...):

import re

texto = "http://ucb.edu.bo y https://google.com"

# (?:...) agrupa pero NO captura
patron = r"(?:https?://)(\w+\.\w+(?:\.\w+)?)"
dominios = re.findall(patron, texto)

print(f"Solo dominios (sin protocolo): {dominios}")
Solo dominios (sin protocolo): ['ucb.edu.bo', 'google.com']

¿Cuándo usar (?:...)?

Cuando necesitas agrupar para aplicar un cuantificador o alternancia, pero no te interesa capturar ese grupo.

Regex en Python

El módulo re

Python incluye el módulo re para trabajar con expresiones regulares:

Función Descripción
re.search(p, s) Busca la primera coincidencia
re.match(p, s) Busca solo al inicio de la cadena
re.findall(p, s) Devuelve lista de todas las coincidencias
re.finditer(p, s) Devuelve iterador de objetos Match
re.sub(p, r, s) Reemplaza coincidencias
re.split(p, s) Divide la cadena por el patrón
re.compile(p) Compila el patrón para reutilizar

re.search() vs re.match()

import re

texto = "Python es genial"

# search() busca en cualquier parte
resultado_search = re.search(r"es", texto)
print(f"search('es'): {resultado_search.group() if resultado_search else 'No encontrado'}")

# match() solo busca al INICIO
resultado_match = re.match(r"es", texto)
print(f"match('es'): {resultado_match.group() if resultado_match else 'No encontrado'}")

# match() funciona si el patrón está al inicio
resultado_match2 = re.match(r"Python", texto)
print(f"match('Python'): {resultado_match2.group() if resultado_match2 else 'No encontrado'}")
search('es'): es
match('es'): No encontrado
match('Python'): Python

re.findall() y re.finditer()

import re

texto = "Precios: $100, $250, $75 y $1000"

# findall() devuelve lista de strings
precios_str = re.findall(r"\$(\d+)", texto)
print(f"findall(): {precios_str}")

# finditer() devuelve objetos Match con más información
print("\nfinditer():")
for match in re.finditer(r"\$(\d+)", texto):
    print(f"  '{match.group()}' en posición {match.start()}-{match.end()}, valor: {match.group(1)}")
findall(): ['100', '250', '75', '1000']

finditer():
  '$100' en posición 9-13, valor: 100
  '$250' en posición 15-19, valor: 250
  '$75' en posición 21-24, valor: 75
  '$1000' en posición 27-32, valor: 1000

re.sub() - Sustitución

import re

texto = "Mi teléfono es 591-78945612 y mi CI es 12345678"

# Censurar números de teléfono
censurado = re.sub(r"\d{3}-\d{8}", "[TELÉFONO OCULTO]", texto)
print(f"Censurado: {censurado}")

# Usar grupos en el reemplazo con \1, \2, etc.
fecha = "2026-02-04"
fecha_formato = re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\3/\2/\1", fecha)
print(f"Fecha reformateada: {fecha_formato}")
Censurado: Mi teléfono es [TELÉFONO OCULTO] y mi CI es 12345678
Fecha reformateada: 04/02/2026

re.compile() - Compilación

Cuando usamos el mismo patrón múltiples veces, es más eficiente compilarlo:

import re

# Compilar el patrón UNA vez
patron_email = re.compile(r"\b[\w.]+@[\w.]+\.\w{2,}\b", re.IGNORECASE)

textos = [
    "Contacto: admin@UCB.edu.bo",
    "No hay email aquí",
    "Escribe a soporte@gmail.com o ventas@empresa.com"
]

for texto in textos:
    emails = patron_email.findall(texto)
    print(f"En '{texto[:30]}...': {emails if emails else 'ninguno'}")
En 'Contacto: admin@UCB.edu.bo...': ['admin@UCB.edu.bo']
En 'No hay email aquí...': ninguno
En 'Escribe a soporte@gmail.com o ...': ['soporte@gmail.com', 'ventas@empresa.com']

Flags (Banderas)

Flag Abreviatura Descripción
re.IGNORECASE re.I Ignora mayúsculas/minúsculas
re.MULTILINE re.M ^ y $ aplican a cada línea
re.DOTALL re.S . también coincide con \n
re.VERBOSE re.X Permite comentarios en el patrón
import re

texto = "Python\nPYTHON\npython"

# Sin flag: solo encuentra exacto
print(f"Sin flag: {re.findall(r'python', texto)}")

# Con IGNORECASE
print(f"IGNORECASE: {re.findall(r'python', texto, re.IGNORECASE)}")
Sin flag: ['python']
IGNORECASE: ['Python', 'PYTHON', 'python']

Aplicaciones en NLP

1. Tokenización con Regex

import re

texto = "¡Hola! ¿Cómo estás? Bien, gracias :) #NLP @usuario"

# Tokenización simple por espacios (NO ideal)
tokens_simple = texto.split()
print(f"Split simple: {tokens_simple}")

# Tokenización con regex (mejor)
patron_token = r"\w+|[^\w\s]"
tokens_regex = re.findall(patron_token, texto)
print(f"Regex tokens: {tokens_regex}")

# Incluyendo hashtags y menciones como tokens únicos
patron_social = r"[@#]\w+|\w+|[^\w\s]"
tokens_social = re.findall(patron_social, texto)
print(f"Social tokens: {tokens_social}")
Split simple: ['¡Hola!', '¿Cómo', 'estás?', 'Bien,', 'gracias', ':)', '#NLP', '@usuario']
Regex tokens: ['¡', 'Hola', '!', '¿', 'Cómo', 'estás', '?', 'Bien', ',', 'gracias', ':', ')', '#', 'NLP', '@', 'usuario']
Social tokens: ['¡', 'Hola', '!', '¿', 'Cómo', 'estás', '?', 'Bien', ',', 'gracias', ':', ')', '#NLP', '@usuario']

2. Limpieza de Texto

import re

texto_sucio = """
    ¡¡¡Hola!!!   ¿¿Qué tal??  
    Visita https://ejemplo.com para más info...
    Contacto:   admin@mail.com   📧
"""

def limpiar_texto(texto):
    # Eliminar URLs
    texto = re.sub(r"https?://\S+", "", texto)
    # Eliminar emails
    texto = re.sub(r"\S+@\S+", "", texto)
    # Eliminar emojis (simplificado)
    texto = re.sub(r"[^\w\s¿?¡!.,]", "", texto)
    # Normalizar espacios
    texto = re.sub(r"\s+", " ", texto)
    # Normalizar puntuación repetida
    texto = re.sub(r"([¿?¡!.])\1+", r"\1", texto)
    return texto.strip()

print(f"Original:\n{texto_sucio}")
print(f"\nLimpio:\n{limpiar_texto(texto_sucio)}")
Original:

    ¡¡¡Hola!!!   ¿¿Qué tal??  
    Visita https://ejemplo.com para más info...
    Contacto:   admin@mail.com   📧


Limpio:
¡Hola! ¿Qué tal? Visita para más info. Contacto

3. Extracción de Entidades Simples

import re

texto = """
El paciente Juan Pérez (CI: 12345678) tiene cita el 15/03/2026.
Contactar al Dr. García al 591-71234567.
Dirección: Av. 6 de Agosto #1234, La Paz.
"""

# Extraer fechas
fechas = re.findall(r"\d{2}/\d{2}/\d{4}", texto)
print(f"Fechas: {fechas}")

# Extraer teléfonos
telefonos = re.findall(r"\d{3}-\d{8}", texto)
print(f"Teléfonos: {telefonos}")

# Extraer CI (Carnet de Identidad)
ci = re.findall(r"CI:\s*(\d{7,8})", texto)
print(f"CI: {ci}")

# Extraer nombres propios (simplificado: palabra capitalizada)
nombres = re.findall(r"\b[A-ZÁÉÍÓÚ][a-záéíóú]+(?:\s+[A-ZÁÉÍÓÚ][a-záéíóú]+)*\b", texto)
print(f"Posibles nombres: {nombres}")
Fechas: ['15/03/2026']
Teléfonos: ['591-71234567']
CI: ['12345678']
Posibles nombres: ['El', 'Juan Pérez', 'Contactar', 'Dr', 'García', 'Dirección', 'Av', 'Agosto', 'La Paz']

4. Validación de Formatos

import re

def validar_email(email):
    patron = r"^[\w.+-]+@[\w-]+\.[\w.-]+$"
    return bool(re.match(patron, email))

def validar_telefono_bo(telefono):
    # Formato Bolivia: 591-XXXXXXXX o +591 XXXXXXXX
    patron = r"^(\+?591[-\s]?)?\d{8}$"
    return bool(re.match(patron, telefono))

# Pruebas
emails = ["test@ucb.edu.bo", "invalido@", "otro.email@gmail.com"]
for e in emails:
    print(f"'{e}' → {'✅ Válido' if validar_email(e) else '❌ Inválido'}")

print()
telefonos = ["591-78945612", "+591 71234567", "123456"]
for t in telefonos:
    print(f"'{t}' → {'✅ Válido' if validar_telefono_bo(t) else '❌ Inválido'}")
'test@ucb.edu.bo' → ✅ Válido
'invalido@' → ❌ Inválido
'otro.email@gmail.com' → ✅ Válido

'591-78945612' → ✅ Válido
'+591 71234567' → ✅ Válido
'123456' → ❌ Inválido

Ejercicio Práctico

Desafío: Extractor de Hashtags

Escribe una función que extraiga todos los hashtags de un texto de red social.

Code
import re

def extraer_hashtags(texto):
    """
    Extrae hashtags de un texto.
    Un hashtag empieza con # seguido de letras/números/guiones bajos.
    """
    patron = r"#\w+"
    return re.findall(patron, texto)

# Prueba
tweet = "Aprendiendo #NLP con #Python es muy #divertido! 🎉 #MachineLearning #IA"
hashtags = extraer_hashtags(tweet)
print(f"Texto: {tweet}")
print(f"Hashtags: {hashtags}")
Texto: Aprendiendo #NLP con #Python es muy #divertido! 🎉 #MachineLearning #IA
Hashtags: ['#NLP', '#Python', '#divertido', '#MachineLearning', '#IA']

Tu turno

¿Cómo modificarías el patrón para que también capture hashtags con acentos como #ProgramaciónEnEspañol?

Resumen y Recursos

Lo que Aprendimos Hoy ✅

Conceptos:

  • Qué son las expresiones regulares
  • Metacaracteres: . ^ $ \d \w \s
  • Clases de caracteres: [abc] [^abc]
  • Cuantificadores: * + ? {n,m}
  • Greedy vs. Lazy

Python:

  • Módulo re
  • search(), match(), findall()
  • sub(), split(), compile()
  • Grupos de captura () y (?P<name>)
  • Flags: IGNORECASE, MULTILINE

Recursos para Practicar 📚

Herramientas online:

Lectura recomendada:

Próxima Sesión: Preprocesamiento 🔧

S3: Tokenización, Lematización y Stemming

  • Tokenización avanzada con NLTK y spaCy
  • Diferencia entre stemming y lematización
  • Normalización de texto para NLP

Preparación:

pip install nltk spacy
python -m spacy download es_core_news_sm

¿Preguntas? 🙋

¡Gracias!

📧 fsuarez@ucb.edu.bo

🔗 Materiales: github.com/fjsuarez/ucb-nlp