Informática geek, matemáticas, pensamiento crítico y alguna otra cosa de vez en cuando.

2017-04-13

Generador de palabras

Hace mucho tiempo, en una gal... Perdón, que me despisto. Hace mucho tiempo, decía, tuve un QL. Era un ordenador bastante interesante. Una de las aplicaciones que incluía era el Archive, un gestor de base de datos con un lenguaje de programación incorporado. Siendo el primer lenguaje estructurado que aprendía, me fascinó el hecho de que no tenía GOTO, y sin embargo se seguía pudiendo hacer lo mismo que en BASIC, pero de forma más ordenada y disciplinada. Además, tenía una característica que no he vuelto a ver en otro lenguaje de programación hasta que apareció el Python 3, y es que permitía usar ñ y letras acentuadas en los identificadores. Pero me estoy desviando de nuevo...

Uno de los programas que hice en Archive era un generador de palabras. La idea era mostrar una lista de palabras generadas, que sonaran más o menos como cualquier otra palabra castellana. Las primeras versiones no eran muy útiles, pero fui refinándolo hasta que estuve satisfecho. Fue divertido; algunos de mis amigos lo encontraron fascinante.

Para hacer un generador de palabras decente, no basta con juntar letras al azar; eso es un poco como usar bogosort para ordenar: la cantidad de palabras siquiera con un mínimo interés es demasiado reducida entre toda la morralla, con lo que uno se cansa antes de encontrar una. Para abordar el problema adecuadamente, hay que tener en cuenta la estructura de la sílaba en castellano.

Las primeras versiones de aquel programa generaban un número al azar de sílabas, escogiendo la cabeza (o ataque, según versiones), cima (o núcleo) y coda también al azar. El resultado fue peor de lo que esperaba. Tenía que tener en cuenta también la frecuencia de las letras y de los componentes de la sílaba, incluyendo el vacío. Con ese y otros refinamientos (como reglas de exclusión, donde «n» no puede ir seguida de «p» y otras), al final conseguí uno que daba un resultado digno de enseñar a mis amigos. Seguía generando bastantes palabras inservibles, como «brunspridreñustro», pero al menos había entre ellas unas cuantas que sí eran buenas.

Lamentablemente, estoy hablando de memoria, porque perdí ese programa cuando devolví el QL. Como ordenador me gustaba, pero el medio de almacenamiento (microdrives) era horrendo, y cada dos semanas me tocaba hacer un viaje al servicio técnico para una alineación de cabezales. Pudo con mi paciencia.

Pero desde entonces siempre he tenido el gusanillo de tener un generador de palabras. Recientemente me puse manos a la obra y me saqué esa espina. Con más bagaje en mi haber, y la experiencia anterior, me decidí a enfocarlo desde un punto de vista que debería generar palabras de aún mejor calidad que aquel programa de Archive.

Para hacer que sonara castellano, la cabeza, cima y coda debían no sólo obedecer las reglas de formación silábica, sino además tener aproximadamente la misma frecuencia que en nuestro idioma. Consideré usar cadenas de Markov, pero rechacé la idea por temor a que no generaran suficiente variedad, que el resultado careciera de «imaginación». En vez de eso, decidí que el programa debía generar sílabas con frecuencias separadas para las sílabas primera, última e intermedias, además de para monosílabos, bajo la hipótesis de que las frecuencias varían mucho en esos casos especiales. Pero ¿cómo obtener esas frecuencias?

La respuesta era obvia: usaría un texto castellano, separándolo en sílabas y sus componentes y computando las frecuencias deseadas. Así que el primer paso sería hacer un separador silábico de textos.

Lo escribí en Python 3. Las expresiones regulares son un invento maravilloso que usado adecuadamente puede ahorrar mucho tiempo, y en este caso la tarea íntegra de la división silábica recae sobre una sola expresión regular. He aquí el programa:


#!/usr/bin/env python3
# encoding: UTF-8
import sys
import re

# Este módulo carece de una tabla de excepciones a las reglas de separación
# silábica. Por ello, algunas divisiones no las hace correctamente.
# Por ejemplo:
#   liando -> li-an-do. Considera 'ia' como diptongo y la divide como lian-do.
#   subrayar -> sub-ra-yar. La divide como su-bra-yar.


expresión_sin_vocales = re.compile('^[^aeiouáéíóúü]*$')

expresión_sílaba = re.compile(
'('
  # ataques normales
  '[bcfgkp][lr]|tr|dr|rr|ll|ch|qu|[bcdfghj-nprstv-zñ]'
  # todas las consonantes a comienzo de palabra
  '|^[^aeiouáéíóúü]+(?=[aeiouáéíóúü])'
'|)' # no usamos '?' porque nos interesa cadena vacía en vez de Null
# núcleo
# palabras como ahu-yen-tar y a-hue-car son un reto para la expresión regular
'([iuü]h?[aeoáéó]h?[iu]|[iuü]h?[aeoáéó]|[aeoáéó]h?[iu](?!h?[aeoáéó])|[iu]h?[iuíú]|[aeiouáéíóúü])'
# coda = todas las consonantes que no empiezan una nueva sílaba
# (así que repetimos la subexpresión del ataque aquí)
'('
  '(?:(?!(?:[bcfgkp][lr]|tr|dr|rr|ll|ch|qu|[bcdfghj-nprstv-zñ])[aeiouáéíóúü])'
   '[^aeiouáéíóúü])*'
')'
)

def silabea(palabra, separar_partes_sílaba = True):
  # si no tiene vocales, devuelve la palabra intacta
  # p.ej. "y", "sr", etc.)
  if expresión_sin_vocales.search(palabra):
    if separar_partes_sílaba:
      return palabra + '::'
    else:
      return palabra

  estado = 0
  cabeza = 0
  cima = 0
  coda = 0
  total = ''
  acumulado = '' # nos servirá para saber si nos dejamos algo al final
  fin_sílaba_anterior = 0
  for sílaba in expresión_sílaba.finditer(palabra):
    # Comprobar si nos hemos dejado algo entre la coincidencia anterior y esta.
    if sílaba.start(0) != fin_sílaba_anterior:
      fragmento = palabra[fin_sílaba_anterior:sílaba.start(0)]
      total = total + '-(' + fragmento + ')'
      acumulado = acumulado + fragmento
      del fragmento
    acumulado = acumulado + sílaba.group(0)
    fin_sílaba_anterior = sílaba.end(0)

    if separar_partes_sílaba:
      total = total + '-' + sílaba.group(1) + ':' + sílaba.group(2) + ':' + sílaba.group(3)
    else:
      total = total + '-' + sílaba.group(0)

  assert(len(acumulado) >= len(total))

  if len(acumulado) > len(palabra):
    total = total + '-(' + palabra[len(acumulado):] + ')'

  return total[1:]


if __name__ == "__main__":
  divide = len(sys.argv) > 2
  for x in sys.stdin.readlines():
    if x[-1:] == u'\n':
      x = x[:-1]

    print(silabea(x, divide))

La entrada debe ser una lista de palabras, a palabra por línea. La salida es la misma lista, pero con guiones entre las sílabas y con símbolos «:» separando cabeza, cima y coda de cada sílaba. Si se añade un argumento cualquiera, entonces no separa los componentes de cada sílaba, sólo las sílabas en sí, es decir, sólo añade guiones. Toma decisiones arbitrarias en casos dudosos, ya que para este propósito no me importa de qué forma se dividan las palabras que admiten más de una separación (como atlántico).

El texto que escogí para extraer las frecuencias fue Fortunata y Jacinta, disponible libremente a través del proyecto Gutenberg. La mayoría del trabajo auxiliar a la separación lo hice con comandos estándar de Unix, sobre todo sed, pero también sort, uniq, tr, cut y otros. La tabla final de frecuencias se puede consultar abriendo el código fuente de esta página. Hay doce tablas en total, correspondientes a ataque (A), núcleo (N) y coda (C) de monosílabos (1), inicios de palabra (I), sílabas medias (M) y finales de palabra (F).

La frecuencia de las longitudes (en sílabas) ha sido también analizada y es respetada por el programa. Puede generar hasta 8 sílabas. Las palabras (sin repetición) más frecuentes en el texto de Galdós son las trisílabas, seguidas de las tetrasílabas. Me remito de nuevo al código fuente para más detalles.

Sigue habiendo algo de morralla en el resultado, pero la densidad de palabras interesantes es ahora muy satisfactoria. Incluso genera con cierta frecuencia una palabra que ya existe o se diferencia en muy poco de una existente. No genera tildes, así que la acentuación es cosa de cada cual. En ocasiones, una palabra es «casi perfecta», a falta sólo de un pequeño toque que le podemos dar manualmente.

Ya sin más preámbulos, he aquí un ejemplo del resultado: una colección de palabras recién generada. ¡Que la disfrutéis!

No comments: