7.2. Caso de estudio: direcciones de calles

Esta serie de ejemplos la inspiró un problema de la vida real que surgió en mi trabajo diario hace unos años, cuando necesité limpiar y estandarizar direcciones de calles exportadas de un sistema antiguo antes de importarlo a un nuevo sistema (vea que no me invento todo esto; es realmente útil). Este ejemplo muestra la manera en que me enfrenté al problema.

Ejemplo 7.1. Buscando el final de una cadena

>>> s = '100 NORTH MAIN ROAD'
>>> s.replace('ROAD', 'RD.')               1
'100 NORTH MAIN RD.'
>>> s = '100 NORTH BROAD ROAD'
>>> s.replace('ROAD', 'RD.')               2
'100 NORTH BRD. RD.'
>>> s[:-4] + s[-4:].replace('ROAD', 'RD.') 3
'100 NORTH BROAD RD.'
>>> import re                              4
>>> re.sub('ROAD$', 'RD.', s)              5 6
'100 NORTH BROAD RD.'
1 Mi objetivo es estandarizar la dirección de calle de manera que 'ROAD' siempre quede abreviado como 'RD.'. A primera vista, pensé que era suficientemente simple como para limitarme a usar el método de cadena replace. Después de todo, los datos ya estaban en mayúsculas, así que no habría problema con diferencias de mayúsculas. Y la cadena a buscar, 'ROAD', era constante. Y en este ejemplo engañosamente sencillo, s.replace funciona.
2 La vida, desafortunadamente, está llena de contraejemplos, y descubrí éste enseguida. El problema aquí es que 'ROAD' aparece dos veces en la dirección, una como parte del nombre de la calle 'BROAD' y otra como la propia palabra. La función replace ve las dos apariciones y las reemplaza ambas sin mirar atrás; mientras tanto, yo veo cómo mi dirección queda destruida.
3 Para resolver el problema de las direcciones con más de una subcadena 'ROAD', podríamos recurrir a algo como esto: buscar y reemplazar 'ROAD' sólo en los últimos cuatro caracteres de la dirección (s[-4:]), y dejar el resto sin modificar (s[:-4]). Pero como puede ver, esto ya se está volviendo inmanejable. Por ejemplo, el patrón depende de la longitud de la cadena que estamos reemplazando (si quisiera sustituir 'STREET' por 'ST.', necesitaría usar s[:-6] y s[-6:].replace(...)). ¿Le gustaría volver en seis meses y tener que depurar esto? Sé que a mí no me gustaría.
4 Es hora de pasar a las expresiones regulares. En Python, toda la funcionalidad relacionada con las expresiones regulares está contenida en el módulo re.
5 Eche un vistazo al primer parámetro: 'ROAD$'. Ésta es una expresión regular sencilla que coincide con 'ROAD' sólo cuando se encuentra al final de una cadena. El $ significa “fin de la cadena” (existe el carácter correspondiente, el acento circunflejo ^, que significa “comienzo de la cadena”).
6 Usando la función re.sub, buscamos la expresión regular 'ROAD$' dentro de la cadena s y la reemplazamos con 'RD.'. Esto coincide con ROAD al final de la cadena s, pero no con la ROAD que es parte de la palabra BROAD, porque está en mitad de s.

Siguiendo con mi historia de adecentar las direcciones, pronto descubrí que el ejemplo anterior que se ajustaba a 'ROAD' al final de las direcciones, no era suficientemente bueno, porque no todas las direcciones incluían la indicación del tipo de calle; algunas simplemente terminaban en el nombre de la calle. La mayor parte de las veces, podía pasar con eso, pero si la calle se llamaba 'BROAD', entonces la expresión regular coincidiría con el 'ROAD' del final de la cadena siendo parte de 'BROAD', que no es lo que yo quería.

Ejemplo 7.2. Coincidencia con palabras completas

>>> s = '100 BROAD'
>>> re.sub('ROAD$', 'RD.', s)
'100 BRD.'
>>> re.sub('\\bROAD$', 'RD.', s)  1
'100 BROAD'
>>> re.sub(r'\bROAD$', 'RD.', s)  2
'100 BROAD'
>>> s = '100 BROAD ROAD APT. 3'
>>> re.sub(r'\bROAD$', 'RD.', s)  3
'100 BROAD ROAD APT. 3'
>>> re.sub(r'\bROAD\b', 'RD.', s) 4
'100 BROAD RD. APT 3'
1 Lo que yo quería de verdad era una coincidencia con 'ROAD' cuando estuviera al final de la cadena y fuera una palabra en sí misma, no parte de una mayor. Para expresar esto en una expresión regular, usamos \b, que significa “aquí debería estar el límite de una palabra”. En Python, esto se complica debido al hecho de que el carácter '\' ha de escaparse si está dentro de una cadena. A veces a esto se le llama la plaga de la barra inversa, y es una de las razones por las que las expresiones regulares son más sencillas en Perl que en Python. Por otro lado, Perl mezcla las expresiones regulares con el resto de la sintaxis, de manera que si tiene un fallo, será difícil decidir si es a causa de la sintaxis o de la expresión regular.
2 Para evitar la plaga de la barra inversa, puede usar lo que se denominan cadenas sin procesar[3], prefijando una letra r a la cadena. Esto le dice a Python que nada de esa cadena debe ser escapado; '\t' es un carácter de tabulador, pero r'\t' es en realidad el carácter de la barra \ seguido de la letra t. Le recomiendo que siempre use estas cadenas cuando trabaje con expresiones regulares; de otro modo, las cosas pueden volverse confusas rápidamente (y las expresiones regulares ya se vuelven confusas suficientemente rápido por sí mismas).
3 *suspiro* Desafortunadamente, pronto encontré más casos que contradecían mi lógica. En este caso, la dirección de la calle contenía la palabra 'ROAD' por separado, pero no estaba al final, ya que la dirección incluía un número de apartamento tras la indicación de la calle. Como 'ROAD' no estaba justo al final de la cadena, no había coincidencia, de manera que la invocación a re.sub acababa por no reemplazar nada, y obtenía la cadena original, que no es lo que deseaba.
4 Para resolver este problema, eliminé el carácter $ y añadí otro \b. Ahora la expresión regular dice “busca una 'ROAD' que sea una palabra por sí misma en cualquier parte de la cadena”, ya sea al final, al principio, o en alguna parte por en medio.

Footnotes

[3] raw strings