Diccionarios vs algoritmos generadores

El mundo está lleno de grandes enfrentamientos. Espacios vs tabuladores, Android vs iOs, …. Hoy toca diccionarios vs algoritmos generadores. El motivo del enfrentamiento es que una palabra se puede tener diversas formas, por ejemplo los verbos tienen prácticamente una forma distinta dependiendo de la persona, el tiempo o el modo. Un nombre tiene masculino y femenino y sus respectivos plurales. ¿Como recopilamos todas esas variaciones? Hay dos opciones principales: diccionarios y algoritmos generadores.

Diccionarios:

Es el más simple de entender. Almacenamos un listado con cada palabra y sus posibles variaciones.

Ventajas:

  • Es fácil de entender y de modificar.
  • No tiene problemas con las excepciones.

Inconvenientes:

  • Ocupa mucho espacio.
  • No sabe qué hacer con las palabras nuevas que no están en el diccionario.
  • Es costoso y difícil recopilar todos los casos.

Algoritmos generadores:

Se basan en usar un algoritmo que a partir de una forma de la palabra genera todas las demás.

Ventajas:

  • Ocupa poco espacio.
  • Pueden trabajar con palabras nuevas sin problemas.

Inconvenientes:

  • No pueden trabajar con excepciones.
  • Son difíciles de modificar.
  • A veces tienes que aplicar varios algoritmos para ir de una forma a otra ya que no hay uno directo.

Algoritmos generadores con diccionarios de excepciones:

Siempre hay una tercera opción que es mezclar las otras dos. Se usan algoritmos generadores para generar la nueva forma de la palabra, y solo se almacenan en el diccionario las excepciones a estos algoritmos. Ocupa menos espacio y es capaz de tener en cuenta las excepciones.

Por ejemplo, podríamos tener un algoritmo generador realmente simple que tuviera esta regla:

«Si la palabra femenina acaba en ‘a’ el masculino remplaza la ‘a’ por ‘o'»

Funciona bien la mayoría de los casos, pero sería necesario tener un diccionario que indique que el femenino de ‘vaca’ es ‘toro’ y no ‘vaco’.

Pero no todo es alegría y regocijo. Aún con este sistema es difícil ser exhaustivo y tener en cuenta todos los posibles casos. Además de que el lenguaje es algo vivo que siempre está creciendo y cambiando por lo que hay que realizar un esfuerzo por mantenerlo actualizado.

Encontrando la sílaba tónica

La sílaba tónica es aquella que se pronuncia con más énfasis en la frase. Encontrarla es útil para saber el significado de las palabras ya que muchas pueden cambiar con la pronunciación. Por ejemplo: médico, medico y medicó.

El algoritmo para encontrarla en español es muy simple. Vamos a suponer que ya tenemos la palabra dividida en sílabas. Si es monosílaba, no  y hay mucho donde elegir, está claro que esa sílaba es la tónica. Si tiene más sílabas hay que buscar cual tiene la tilde. Si no la tiene ninguna solo puede ser llana o aguda. Si termina en «n»,»s» o vocal y no tiene tilde es llana. Si termina en cualquier otra letra es aguda.

Y ya está, simple y rápido.

Este algoritmo está incluido en la librería jsESsyllable .


var syllable = new jsESsyllable();

var word = "...";
var syllables = syllable.divide(word);
var strong = syllable.stress(syllables);

Puedes probarlo online.

Separar palabras en sílabas

Este algoritmo esta inspirado en el paper «A Syllabification Algorithm for Spanish» de Heriberto Cuayáhuitl. Pero realizando algunos cambios.

Para separar las palabras en sílabas usamos un algoritmo de dos pasos.

Paso 1

La clave del primer paso está en analizar las consonantes entre vocales. Podemos tener los siguientes casos: VCV, VCCV, VCCCV, VCCCCV. Siempre dividimos por delante de la última consonante: V|CV, VC|CV, VCC|CV, VCCC|CV . Sin embargo si la última consonante es una ‘r’, ‘l’ o ‘h’. Estamos ante una estructura de dos consonantes como cr, fr, rr, ll, ch,… Por lo que desplazaremos el corte a la consonante anterior. Hay que tener en cuenta algún detalle más. Cuando hay varias vocales juntas actúan como si fueran una sola. Así que realmente las V pueden representar a varias vocales.

El algoritmo necesita una variable temporal donde se almacena lo queda a la derecha del punto de corte. Pero antes de actualizarla esa variable hay que tomar el valor almacenado en ella, añadirle lo que queda a la izquierda del punto de corte y almacenarlo como una de las silabas. Puede que algunas de la silabas no sean correctas, de ellas se ocupará el paso 2.

El algoritmo elimina la parte analizada de la palabra y es como si siempre estuviera al principio de la misma. Para ello el algoritmo al cortar un bloque de vocales y consonantes toma el lado de la izquierda, lo junta con lo que haya almacenado en la variable temporal (el lado de la derecha del anterior corte), eso forma una silaba y la guarda. Luego almacena en la variable la parte de la derecha del corte hasta poder unirlo con la parte izquierda del siguiente corte (si no hubiera más cortes lo guarda y finaliza).

Al final las reglas que quedan son:

La palabra comienza por….GuardarVariable Temporal (T)
V -> | VV
CV -> | CVTCV
C[rlh]V -> | C[rlh]VTC[rlh]V
CCV -> C | CVT+CCV
CC[rlh]V -> C | C[rlh]VT+CC[rlh]V
CCCV -> CC | CVT+CCCV
CCC[rlh]V -> CC | C[rlh]VT+CCC[rlh]V
CCCCV -> CCC | CVT+CCCCV
C* -> C* |T+C*

Siendo: V = vocal/es, C = Consonante, [rlh] = consonante que sea r, l, h, T = variable temporal, | = punto de corte

La última regla hace referencia a que solo quedan caracteres y ninguna vocal, en ese caso se unen todo a lo que haya en la memoria temporal.

Ejemplos:

PalabraReglaBloqueGuardarTemporalSílabas
instrumentoV -> |V| i i
nstrumentoCCC[rlh]V -> CC | C[rlh]Vns | trui+nstruins,
mentoCV -> | CV| metrumeins, tru
ntoCCV -> C | CVn | tome+ntoins, tru, men
  to ins, tru, men, to
PalabraReglaBloqueGuardarTemporalSílabas
trompetaC[rlh]V -> | C[rlh]V| tro tro
mpetaCCV -> C | CVm | petro+mpetrom,
taCV -> | CV| tapetatrom, pe
tatrom, pe, ta
PalabraReglaBloqueGuardarTemporalSílabas
aviónV -> |V| a a
viónCV -> | CV| vióavióa,
nC* -> C* |n | vió + na, vión

 Paso 2

En el segundo paso repasamos cada uno de los grupos creados en el paso anterior y vamos a dividir los grupos de vocales cuando sea necesario. Separaremos las vocales en dos grupos: débiles {i,u} y fuerte {a,e,o}. Denotaremos el grupo de débiles con d y el de fuertes con f. A su vez pueden estar acentuadas o no, lo indicaremos como D {í,ú} y F {á,é,ó}.

Para dos vocales juntas las reglas son:

  • Permanecen juntas: dd, df, dF, Fd
  • Se separan:  ff, Ff, fF, Df, fD

En el caso de tres vocales solo se separan si hay dos fuertes juntas.

  • {iuüíú}{aeoáéó}{aeoáéó} -> {iuüíú}{aeoáéó} | {aeoáéó}
  • {aeoáéó}{aeoáéó}{iuüíú} -> {aeoáéó} | {aeoáéó}{iuüíú}

Ejemplo:

«avión» tras el paso uno queda dividida en «a»,»vión». Al ser «vión» un caso dF las vocales no se dividen, el paso dos devolverá «a»,»vión»

«rocíen» se separará en el paso uno en «ro»,»cíen». Como íe es un caso de Df se separara «ro»,»cí»,»en»

En cambio «ca»,»ca»,»hue»,»te» en el caso de «hue» al ser df seguirán juntas.


Todo esto está recopilado en la librería jsESsyllable.

var syllable = new jsESsyllable();

var word = "...";
var syllables = syllable.divide(word);

Puedes usar la demo online.

Tienes un vídeo explicando este mismo algoritmo en mi canal de youtube:

Haz click para ver el vídeo en mi canal de youtube

Capturar vídeo de la cámara del dispositivo en HTML5

Para dotar a nuestro agente de ojos debemos poder acceder a las cámaras del dispositivo. Para ellos nuestra web va a necesitar un elemento vídeo y otro canvas. Enlazamos el vídeo con la webcam del dispositivo y cada cierto tiempo tomamos una captura que copiaremos sobre el canvas para poder acceder a los pixels y aplicar nuestros algoritmos de visión por computador.
Empecemos por incluir una etiqueta vídeo y una canvas en nuestra web:



Las funciones que nos da HTML5 para acceder al vídeo se basan en la API navigator.mediaDevices. En versiones antiguas de los navegadores pierdes encontrarte el problema de que los nombres no están unificados y cada uno lo llamaba de una manera. Actualmente ya lo están. Algo parecido nos pasa con la API window.URL que vamos a usar para poder conectar la webcam a nuestro elemento vídeo. Para que no haya problemas entre navegadores usaremos:

window.URL = window.URL
|| window.webkitURL
|| window.mozURL
|| window.msURL;

Nos hace falta acceder a cada uno de los elementos. El vídeo, el canvas y el contexto del canvas del cual leeremos la imagen capturada del vídeo. Para ello usaremos el id del tag.

video = document.getElementById(videoId);
canvas = document.getElementById(canvasId);
context = canvas.getContext('2d');

Para enlazar la webcam con el elemento vídeo antes hay que saber que webcam es ya que un dispositivo puede tener varias, por ejemplo un móvil tiene frontal y trasera. Para saber que dispositivos hay puedes usar la API navigator.mediaDevices.enumerateDevices . Una vez seleccionado el dispositivo que quieras usar se emplea el metodo getUserMedia() pasandole una estructura del tipo MediaStreamConstraints donde describes los requisitos que necesitas que tenga el dispositivo.  En nuestro caso va ser mas simple vamos a contemplar solo la cámara frontal, la trasera y la por defecto.

//Cámara frontal
device.video = {facingMode: "user", deviceId: ""};
//Cámara trasera
device.video = {facingMode: "environment", deviceId: ""};
//Cámara por defecto (frontal en los móviles)
device.video = {deviceId: "default"};

También podemos desear ajustar la resolución:

device.video.width = 320;
device.video.height = 240;

y el framerate.

this.configuration.framerate = 25;

Hay que decir que realmente estas condiciones no obligan que devolver un dispositivo que las cumpla, solo aconsejan que lo sea, así que no puedes confiar que la resolución sea la deseada y debes de verificarlos.

navigator.mediaDevices.getUserMedia(device)
.then(
  function(stream) { ...}
).catch(...)

Una vez conectada a la webcam hemos de conectar esta con el elemento vídeo de la web para ello usamos window.URL.createObjectURL :

navigator.mediaDevices.getUserMedia(device)
.then(
  function(stream) {
    var src = window.URL.createObjectURL(stream);
    video.src = src;
  }
).catch(
  function(e) { console.log("Video error: "+e); }
);

Y por ultimo cada cierto tiempo hemos de capturar el frame que se esta mostrando en el video y volcarlo al canvas, desde donde podremos acceder a todos sus pixeles. El proceso es muy sencillo, tan sencillo como copiarla al canvas usando el método drawimage

canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.context.drawImage(video, 0, 0, this.video.videoWidth, this.video.videoHeight);

Lo mismo con videoToCanvas

Para hacer todo esto más sencillo se ha creado la librería videoToCanvas

//id del tag canvas y video
var v2c = new VideoToCanvas("canvas", "video");
//por defecto es 320x240
v2c.configuration.width=640;
v2c.configuration.height=480;
v2c.webcam();

Para realizar la captura al canvas es tan simple como:

v2c.snap();

Ademas cuenta con gran cantidad de funciones para controlar la reproducción del vídeo de la cámara o para cargar en su lugar un vídeo (útil para pruebas).

Acceder a los pixels de un canvas

Con esto tenemos ya el primer paso dado. Capturar la imagen. Antes de empezar a manipular los datos de un canvas tenemos que ver cómo trabajar con ellos.
Para recuperar los datos del canvas usamos la función getImageData del contexto

var imageData = canvas.context.getImageData(0, 0, width, height);
var data = imageData.data;

getImageData nos permite recuperar solo parte de la imagen indicando las coordenadas X e Y de la esquina superior izquierda del rectángulo de la imagen a recuperar y el ancho y el alto. En caso de que con las medidas especificadas recuperemos parte de de fuera de la imagen se devolverán pixels negros transparentes

Usando videoToCanvas tienes dos métodos getImageData(x, y, width, height) y getBoxes(rows, cols). el primero actúa igual que el getimageData del canvas.context y devuelve un ImageData. El segundo divide la imagen en rows*cols partes y devuelve un array de ImageData. Este método es útil cuando se va a trabajar en paralelo sobre distintas partes de la imagen.

En el ImageData devuelto encontramos las siguientes propiedades:

  • ImageData.height Alto de la imagen
  • ImageData.width Ancho de la imagen
  • ImageData.data Un array de bytes que contiene los pixels de la imagen en formato RGBA

Los píxels de la imagen se recuperan en formato RGBA. Que significa que cada píxel ocupa 4 bytes. Los 3 primeros se dedican a los componentes de color: rojo, verde y azul. El cuarto al nivel de transparencia, también llamado alpha. De esa forma data[0] corresponde al valor de la componente roja del primer píxel, data[1] a la verde, data[2] a la azul y data[3] a la alpha. Después repetimos esa misma distribución con data[4], data[5], data[6] y data[7]. Y así una y otra vez durante toda el array.

Los valores de cada pixel varían entre 0 y 255 e indican la intensidad de ese color en el pixel. Una ventaja del tipo de datos Uint8ClampedArray es que no hace falta comprobar los límites al asignarle un valor, todo valor menor que 0 se convierte a 0 y todo valor mayor de 255 se convierte a 255. Su mayor problema es que es un tipo bastante lento para operar con él así que vamos a tratar de reducir el número de operaciones sobre el mismo.

Flexiones y derivaciones de las palabras.

Uno de los mayores problemas que tenemos para conseguir que nuestro agente pueda tanto interpretar como generar con más naturalidad las frases ha de ser capaz de manejar las flexiones y derivaciones de nuestro lenguaje, al menos de una forma básica.

Eso le permite usar e interpretar correctamente el género, número, conjugaciones y tiempos verbales. Incluso aumentativos y diminutivos.

Reconozcamos que conversaciones como:

– Chispas Hola, soy Lorena

– Hola señor Lorena

– Chispas enciende las luces

– La luces está encendido

Quedan muy absurdas.

Desgraciadamente el lenguaje no se construye pensando en que sea fácil procesarlo por un algoritmo y al final el código acaba siendo un montón de reglas y excepciones. Hay que asumir que no son 100% correctos y que van a fallar en casos concretos que se salten las reglas. Hay mecanismos para incluir estás excepciones. Aún así hay que tratar de controlar el léxico usado. Como siempre es más fácil controlarlo si reducimos el lenguaje a un contexto concreto.

Pero es que hay que tener en cuenta que  todo puede funcionar bien y una palabra puede estar correctamente cambiada de género y número y aún así ser incorrecto su uso. Por ejemplo: «El manzano está en flor» si lo pasamos a femenino «La manzana está en flor» aunque manzana es correcto cambia el sentido de la frase. Ejemplos hay a montones «caso, casa», «paso, pasa»… Desgraciadamente es difícil de controlar ya que no solo depende de la palabra en sí, si no también del contexto y de su significado.

Además a veces el paso de una forma otra otro no es sencillo ya que se pierde información de la palabra original. Por ejemplo del plural al singular. En el caso de la palabras acabadas en es no queda claro cuáles tienes que quitar la terminación -es y cuales solo la -s.

Meses -> Mes

Ceses -> Cese

A todos estos problemas hay que contarle varios tipos de excepciones:

  • Verbos: llevan sus propias reglas (conjugaciones verbales) y varían según el género, número y tiempo.
  • Nombres propios: estos son un drama, los hay indistinguibles de nombres comunes (Rosa, Mar, Pilar). Los hay invariantes, que no varían con el número o el género: Sócrates y otros que si (María,Mario) y los que varían a veces no siguen las reglas habituales (Carlos, Carla).
  • Invariantes: palabras que no cambian su forma para los distintos géneros y/o números (Gafas).
  • Excepciones: palabras que no cumplen la reglas para cambiar el género o el número (Hombre/Mujer)

Los algoritmos has sido recogidos en el proyecto jsESinflection que está divido en cinco ficheros JS.

Plurales y singulares jsESnumber.js

Masculinos y femeninos jsESgender.js

Aumentativos jsESaugmentative.js

Diminutivos jsESdiminutive.js

Despreciativos jsESdespective.js

Para cargarlas se puede usar el siguite codigo:






var number = new jsESnumber();
var gender = new jsESgender();
var despective = new jsESdespective();
var augmentative = new jsESaugmentative();
var diminutive = new jsESdiminutive();

Las llamadas para transformar las palabras son sencillas:

number.pluralOf(word);
number.singularOf(word);
number.singularOrPlural(word);
gender.feminineOf(word);
gender.masculineOf(word);
gender.masculineOrFeminine(word);
despective.despectiveOf(word);
augmentative.augmentativeOf(word);
diminutive.diminutiveOf(word);

También permite añadir excepciones si las reglas que aplican las funciones no dan el resultado correcto:

number.addException(singular, plural);
gender.addException(masculine, feminine);
augmentative.addException(normal, augmentative);
diminutive.addException(normal, diminutive)
diminutive.addException(normal, despective);

La librería ya incluye las excepciones más comunes pero para declarar las propias es tan sencillo como pasarle el par de palabras. Por ejemplo:

//realmente es innecesario ya esta incluido en la librería
gender.addException("hombre", "mujer");

Me gustaría decir que funciona a la perfección en el 100% de los casos, pero el leguaje tiene demasiadas excepciones y peculiaridades apra que así sea, sin embargo da un buen resultado la mayoría de las veces.

Extraer lemas de un texto

Una vez visto como lematizar palabras vamos a ver una utilidad de ello. Sacar de que va un texto. En realidad sacar los lemas principales de un texto y así poder compararlos. ¿Por qué convertir las palabras de un texto en lemas? Simplemente porque en un texto el mismo términos puede aparecer varias veces en distintas formas (masculino, femenino, plural, singular,…) y la única forma de saber que es el mismo termino es lematizarlos o en nuestro caso usar un stemmer, en concreto jsEStemmer

No todos los lemas van a ser importantes. En un texto largo puedes tener montón de palabras que no estén relacionadas con el tema principal del texto. La idea es que cuantas más veces salga repetido el mismo lema más probable es que el texto trate sobre el. Así una vez extraídos todos los lemas habría que contarlos y agruparlos para quedarnos solo con los más repetidos.
Pero antes de poder extraer los lemas hay que eliminar las stopwords que son palabras que no aportan nada y son tan comunes que casi siempre saldrían como lemas principales. Al final tendrías que casi todos los textos hablan de los lemas «un», «el», «de»,.. palabras que son muy comunes y no nos aportan nada sobre el texto.

Así que el proceso sería:

  1. Eliminar los caracteres no alfabéticos y convertir las mayúsculas a minúsculas. Asi como eliminar tildes, diéresis, etc.
  2. Eliminar stopwords
  3. Lematización la palabras restantes.
  4. Agrupar lemas similares por distancia
  5. Filtrar los lemas y dejar solo los más importantes.

En el caso de nuestra librería primero obtendríamos todos los lemmas y luego los filtraríamos.

var lemmas = stemmer.stemText(text, distance);
var lemmas = stemmer.filterLemmas(lemmas, max, number, percentage);

Se pueden filtrar por:

  • max – Número máximo de lemas que debe contener la lista
  • number – Número mínimo de veces que aparece el lemma en el texto
  • percentage – Porcentaje mínimo de veces que aparece el lemma en el texto

El resultado obtenido​ estará en forma de una estructura con tres datos:

  • Un listado de los lemas «similares»
  • El número de veces que aparecen estos lemas en el texto.
  • El porcentaje que representa respecto al total de palabras del texto sin stopwords.
function Lemma(){
  this.lemmas = []; //lista de lemmas similares
  this.number = 0; //numero de repeticiones en el texto
  this.percentage = 0; //porcentaje de repeticiones en el texto

  this.merge = function(lemma){...} //permite unir dos lemmas
}

¿Como sabemos si un lema es «similar» a otro?. Calculando la distancia entre lo dos. Si son menor que un valor umbral se consideran «idénticos». Esto es necesario porque el proceso de lematización no es tan perfecto como nos gustaría y dos palabras que deberían tener  y el mismo lema pueden generar lemas muy parecidos pero no identificos. Para ello se fija un valor de corte por debajo del cual dos lemas se consideran el mismo y se agrupan bajo la misma estructura. Se suman el número de veces que aparecen y el porcentaje.

Ahora teniendo los lemas principales de un texto podemos compararlo con los lemas de cualquier otro texto, palabra o frase para ver si están relacionados.

//Calcula lo similares que son dos listados de lemas
//El resultado se calcula entre 0 y 1.
var result = stemmer.compareArrayOfLemmas((a, b, threshold));

Medir distancia entre lemas

Ya hemos visto como medir la distancia entre palabras, pero el medir distancia entre lemas plantea un problema diferente a medir el de palabras ya que solo tienen en común el texto que vaya desde el principio del lema hasta la primera letra diferente. Por ejemplo, aunque cos y cas tienen la mayor parte de las letras en común su distancia debería ser alta ya que son lemas de palabras muy diferentes. La distancia que vamos a usar es el número de caracteres en común del principio al primer carácter diferente por dos (son caracteres en común en ambos lemas) dividido entre la suma del número de caracteres de cada palabra.


function distanceLemmas(a,b){
  var shorterLength;
  var totalLength = a.length + b.length;
  if(a.length > b.length){
    shorterLength = b.length;
  } else {
    shorterLength = a.length;
  }

  var i = 0
  for(; i < shorterLength; ++i){
    if(a[i] != b[i])
      break;
  }

  if(i == 0) {
    return 1;
  } else {
    return 1-((i*2)/totalLength);
  }
};

La distancia será un resultado entre 0 y 1. 0 si todos los caracteres coinciden 1 si no coincide ninguno.

Uno de los principales motivos para calcular esta distancia es agrupar lemas similares, ya que tras al lematización los lemas obtenidos para palabras de la misma familia no siempre son el exactamente el mismo, Tras realizar varias pruebas recomendaría que se consideren similares los lemas con una distancia de hasta 0.20-0.25.

Usando la librería jsEStemmer:


var stemmer = new jsEStemmer.stemmer();
var lemma1 = stemmer.stemWord(word1);
var lemma2 = stemmer.stemWord(word2);
var distance = stemmer.distance(lemma1, lemma2);

Medir distancias entre palabras

Para medir la distancia entre dos palabras considerando solo sus caracteres e ignorando su significado. Vamos contar el número mínimo de operaciones que deberíamos realizar sobre una palabra para convertirla en la otra.

¿Que operaciones existen?:

  • Sustitución de un carácter por otro.
  • Inserción de un carácter
  • Eliminación de un carácter
  • Transposición entre dos caracteres adyacentes.

Existen varios algoritmos, según que operaciones se quieran tener en cuenta:

Distancia Sus. Ins. Eli. Tra.
Hamming X
Jaro–Winkler X
LCS X X
Levenshtein X X X
Damerau-Levenshtein X X X X

El uso de estas distancias es muy variado, por ejemplo sugerir correcciones ortográficas o agrupar palabra similares.

Hay que tener en cuanta que esto solo es una medición de la distancia comparando caracteres de dos palabras, no se tiene en cuenta su significado. Por ello aunque «remando» por significado esta más próxima a «remar» que «retar» con estas distancias «remar» y «retar» son casi iguales.

 

Lematización de palabras

Si nos pusieran la típica prueba de «¿Que palabra no está relacionada? Descubridor, descubrimiento, recubrimiento». Creo que todos acertariamos diciendo que es «recubrimiento​». Pero para un ordenador recubrimiento y descubrimiento son casi, casi la misma palabra. Al no entender el significado de las palabras no le queda más remedio que comparar sus caracteres y esa es una mala medida de similitud. Al menos de forma directa ya que en Español hay muchas terminaciones similares que hacen que dos palabras distintas tengan formas parecidas. Por ejemplo pasas con casas tienen más letra en común que con casero cuando esta claro que casero esta más relacionado con casa.

Vamos a empezar por lo más sencillo, comparar palabras. Para ello deberíamos comparar las palabras por su lema. Este proceso se llama lematización. La idea es extraer una raíz común. Hay dos principales formas de hacerlo. Usando un diccionario que asocia cada palabra con su lema. Esto requiere diccionarios enormes que es difícil que contengan todas las posibles palabras. La otra forma de hacerlo es aplicar algún algoritmo que calcule el lema. La pega es que es difícil hacer un algoritmo que calcule correctamente todos los lemas. Como casi siempre que hay dos opciones hay una tercera que es combinar ambas. Usar un diccionario y si no se encuentra ahí el lema calcularlo.

Vamos a usar esta última técnica. Para ello usaremos el algoritmo de Porter

Antes de lematización y para reducir la complejidad hay que eliminar todos los caracteres no alfabéticos. Y reemplazar la mayúscula, letras con tilde, diéresis, etc por la misma letra en minúscula.

Por ejemplo, convertiríamos «pingüino3» en «pinguino».

Sobre este resultado aplicaríamos el algoritmo de lematización.

Para los casos en que sea necesario vamos a incluir un pequeño diccionario de tal forma que podamos incluir una palabra y su lema correspondiente.

Con todo esto se ha creado la librería jsEStemmer para lemmatizar una palabra:

      var stemmer = new jsEStemmer.stemmer();
      var lemma = stemmer.stemWord(word);

 

Reconocimiento del habla en el navegador

Para el reconocimiento del habla desde el navegador usaremos la web speech API que es estandar, aunque en mis pruebas solo me ha funcionado correctamente, con algunos «peros», en Chrome. He de advertir que esta API puede enviar los audios a un servidor externo para su transcripción, lo cual debe tenerse en cuenta a nivel de seguridad y privacidad.

El primer problema que puedes tener es que para acceder a la cámara y el micro el nevegador pide autorización al usuario y solo recuerda su respuesta cuando la página se sirve desde https. No se por qué pero el reconocimiento de voz hay que reiniciarlo cada poco así que si no lo sirves desde https se hace cansado estar recargando todo el rato. Yo acabé usando caddy que permite configurar un servidor web https rápidamente.

Una vez resuelto ese problema vamos a ver el código  para inicializar el reconocimiento del habla en el navegador:

var SpeechRecognition = SpeechRecognition || webkitSpeechRecognition;

var speechRecognition = new SpeechRecognition();
speechRecognition.continuous = true;
speechRecognition.interimResults = false;
speechRecognition.lang = navigator.language || navigator.userLanguage;

speechRecognition.onresult = resultRecognition;
speechRecognition.onend = function(){
  speechRecognition.start();
};

speechRecognition.start();

 

Puedes ver todos los parámetros disponibles aquí. Como ya he comentado antes, uno de los problemas que surgen es que speechRecognition.continuous = true que se supone activa el reconocimiento continuo no va muy bien y de vez en cuando deja de funcionar, para solucionar eso en el evento onend asignamos una función que lo que hace es reiniciar el reconocimiento del habla cuando este se caiga.

En el evento onresult ponemos la función que va a tratar el resultado del reconocimiento del habla. Obtendremos un listado de cadenas de texto con un porcentaje de fiabilidad de que lo el usuario ha dicho es eso. Los resultados van ordenados de mayor a menor porcentaje. Nosotros solo vamos a usar la más probable. Pero si por ejemplo esperas un color y los resultados son: «armadillo» (90%) y «amarillo» (80%) yo apostaría por la segunda.

Con la propiedad speechRecognition.interimResults indicas cuando se lanza el evento onresult. Si es false se lanzará solo cuando el sistema de reconocimiento del habla detecte que has terminado de hablar y tenga una versión final del texto. Eso hace que a veces haya una demora entre que terminas de hablar y que se ejecute la acción. Con el valor a true lo llamará según vaya construyendo el texto. El problema es que el texto puede sufrir cambios según vaya entendiendo nuevas palabras, esos cambios pueden afectar a palabras ya detectadas. Para saber si el texto es final o no se puede consultar la propiedad isFinal de cada resultado.

En nuestro caso la función como tiene marcado que solo se le llame cuando haya terminado el reconocimiento solo tiene que pillar la solución más fiable y analizarla. Una cosa que no entiendo muy bien el motivo es que en eventos.results tienes todas las frases detectadas hasta el momento. En nuestro caso nos interesa la última. Dentro de cada uno de los elementos del array hay otro array con todas las posibles transcripciones y su fiabilidad. ¿Confuso?. Mejor vamos a ver el código:

this.resultRecognition = function(event) {
var resultIndex = event.results.length -1;
var result = event.results[resultIndex];

console.log(result[0].transcript+': '+result[0].confidence);
analyze(result[0].transcript)
};

Una vez obtenida el texto se pasa a una función nuestra para tratar de analizarlo y asociarlo al comando correspondiente. Lo primero es limpiarlo de espacios y ponerlo en minúsculas ya que a veces el texto que devuelve tiene un «original» uso de las mayúsculas

text = text.trim();
text = text.toLowerCase();

Es posible que la transcripción tenga errores. Por ejemplo en mi caso muchas veces en lugar de «chispas» reconoce «chispa» y «chispas di» es imposible que lo entienda siempre transcribe «chispas de». Lo mejor es hacer pruebas y ver cómo se comporta. Pero hay que contemplar que nuestro agente pueda interpretar diferentes versiones del comando. Mi consejo es usar expresiones regulares.