Convertir palabras en números (I)
Tenemos herramientas muy potentes para machacar números, calcular medidas estadísticas, gráficos, distancias, etc. Hoy os propongo jugar con algunas de esas herramientas pero aplicadas a las palabras, al lenguaje. Claro que las letras no son números, hemos de buscar una función que asigne un número a cada combinación de letras. Hemos de buscar una aplicación del conjunto de las palabras a los números.
Nos interesa que las palabras parecidas tengan resultados parecidos, y las diferentes se distancien en el resultado, digamos que de alguna manera nos interesa que sea lineal. Sin duda también buscaremos que mantenga un orden, a ser posible que conserve el mismo orden alfabético al que ya estamos acostumbrados.
Queremos que de cada palabra se obtenga un número distinto. Porque imaginad que dos palabras dan el mismo número. Si tenemos que decir a qué palabra corresponde tal número no podríamos decidirnos por una u otra. O sea que no podríamos volver hacia atrás. Ese tipo de funciones en que un resultado sólo proviene de un origen se llaman inyectivas.
Y hacia atrás, nos gustaría que cualquier número que pongamos se pueda traducir en una combinación de letras -que tenga sentido no importa ahora-. Si no hay huecos en la imagen, los resultados estarán mejor distribuidos. Una función en la que todos los resultados posibles se corresponden con un origen y no hay ningún resultado “suelto” se llama suprayectiva o sobreyectiva.
Las funciones que cumplen las dos propiedades de antes: cada origen tiene un resultado y sólo uno, y cada resultado proviene de un origen y sólo de uno, se llaman biyectivas. Pues eso es lo que nos interesa, para convertir las palabras en números y poder luego volver hacia atrás. Tenéis unas ilustraciones a modo de resumen en la Wikipedia.
Para los que pasen del álgebra, la función que queremos:
- Puede transformar cualquier palabra en un número.
- Puede volver hacia atrás (tiene inversa).
- En abc la letra más importante es la primera, después la segunda, etc. (para mantener el orden alfabético).
- Funciona con palabras de cualquier longitud.
Bases de numeración
La idea para transformar una palabra en un número y que cumpla todas las propiedades anteriores es simple: voy a tomar cada letra como un símbolo en una base de numeración arbitraria.
Ejemplos de bases tenemos la base 10, cuyo conjunto de símbolos todos conocemos: {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}. O la base binaria, que sólo usa {0, 1}. O el sistema hexadecimal, que usa un conjunto ampliado de 16 símbolos: {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, A, B, C, D, E, F}
Yo voy a usar el siguiente conjunto: {@, a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z} y voy a considerar que cada palabra es un número en base 27. ¿Por qué la @ al principio si no es una letra? Porque no quiero que la ‘a’ sea mi cero, si lo fuera palabras como ‘Tomas’ y ‘Tomasa’ tendrían el mismo valor numérico. Más adelante esto nos llevará a perder la suprayectividad, pero compensa.
Ahora bien, en 123 el 1 es el más importante, y el 3 el menos. Sin embargo en abc queremos justamente lo contrario, que ‘a’ sea la letra más importante y ‘c’ la menos. Eso pasa justamente en los números decimales, por ejemplo en 0.123.
Vamos primero a qué significa un número decimal. Recordemos que 0.125 significa:
0.125 = 0 + 1x0.1 + 2x0.01 + 5x0.001
= 0x10<sup>0</sup> + 1x10<sup>-1</sup> + 2x10<sup>-2</sup> + 5x10<sup>-3</sup>
Es decir, vamos dividiendo la base a cada paso. En el caso de ‘abc’ eso sería:
@.abc = 0 + 1x0.037 + 2x0.00137 + 3x0.000051
= 0x27<sup>0</sup> + 1x27<sup>-1</sup> + 2x27<sup>-2</sup> + 3x27<sup>-3</sup>
= 0.039933
Por que dentro de nuestro conjunto de símbolos, la @ ocupa la posición 0, a = 1, b = 2, etc. Llama la atención cómo la base va disminuyendo rápidamente, así que si queremos representar palabras muy largas necesitamos mucha precisión decimal.
Tal como hacemos la conversión,
'zzzzzz...' = 0.99999999...
pero ‘aaaaa…’ no es 0.00000…, eso sería @@@@@@. En realidad
'aaaaa...' = 0.03846153...
El haber introducido el símbolo extra ‘@’ nos impide llegar a 0 y suma una unidad a la base (haciendo que disminuya más rápidamente), pero a cambio tiene este efecto:
tom = 0.761978
toma = 0.7619793
como hemos dicho esto no ocurriría si la ‘a’ valiese 0. Al igual que los ceros a la derecha de un decimal no valen nada, la ‘a’ tampoco valdría nada.
Posiciones decimales
Una pregunta que surge inmediatamente es ¿cuántos caracteres en base 10 (números) necesito para representar N caracteres en base 27 (palabras)?
Si los números no son decimales la respuesta es más facil de ver. Por ejemplo ¿cuantos números en base 2 (cuántos bits) necesito para representar el número 200? Pensemos que cada bit puede representar dos valores (su conjunto sólo tiene dos símbolos). Con N bits prodremos representar 2N combinaciones. Como quiero llegar hasta mínimo 200, que es el número que me piden, exigiré que
2<sup>N</sup> = 200
y despejaré N. Por la definición de logaritmo N es el logaritmo en base 2 de 200.
log<sub>2</sub>(200) = 7.64
necesitaríamos 7 bits y pico, pero como no existe medio dígito (a veces se dice que la precisión de un instrumento es de “tres dígitos y medio”, pero eso es otra cosa), nos quedamos con el entero superior. Ocho bits nos dan para representar 256 combinaciones, o sea valores de 0 a 255.
Con decimales es más complejo. Podemos aplicar la misma técnica, y es válida. Para representar 1 sóla letra necesitamos 1.43 símbolos en base 10.
log<sub>27</sub>(10) = 1.43136
pero al pasar un número decimal a otra base puede ocurrir que salga periódico un número que no lo era (y al revés también). De hecho, nuestra base 27 no tiene divisores comunes con 10, y cualquier numero decimal en base 27 que expresemos en base 10 va a tener infinitos decimales. Aquí entra en juego otra facilidad que nos da la ‘@’. Si prohibimos que ‘@’ forme parte de una palabra podemos terminar la conversión en cuanto nos aparezca, aunque aún quede resto. Eso nos permite redondear los resultados sin perder presición.
La técnica que redondeo que utilizo en pal2num.pl consiste en multiplicar por 1.43 la longitud de la palabra, y quedarme con esa precisión, redondeando siempre al alza el resultado. Por ejemplo:
f = 0.222222...
= 0.23
hola = 0.3174839728...
= 0.3174840
Precisión numérica
Pero el mayor problema no está ahí sino en la precisión de los cálculos en coma flotante. Vamos a hacer una prueba, sabemos que ‘zzzzzzzzzz…’ = ‘0.99999999…’.
$echo zzzzzzzzzzzzzzzzzzzzzzzzz | pal2num |
Es una z, valor 26, base 3.703704E-02, contribucion 0.96296296296296296295894739936116480
Es una z, valor 26, base 1.371742E-03, contribucion 0.03566529492455418381266691631170662
Es una z, valor 26, base 5.080526E-05, contribucion 0.00132093684905756236352230133463195
Es una z, valor 26, base 1.881676E-06, contribucion 0.00004892358700213193939008250007343
Es una z, valor 26, base 6.969172E-08, contribucion 0.00000181198470378266442184724833038
Es una z, valor 26, base 2.581175E-09, contribucion 0.00000006711054458454312673364719404
Es una z, valor 26, base 9.559907E-11, contribucion 0.00000000248557572535344913843282081
Es una z, valor 26, base 3.540706E-12, contribucion 0.00000000009205836019827589401462798
Es una z, valor 26, base 1.311373E-13, contribucion 0.00000000000340956889623244051906030
Es una z, valor 26, base 4.856936E-15, contribucion 0.00000000000012628032949009038958935
Es una z, valor 26, base 1.798865E-16, contribucion 0.00000000000000467704924037371813310
Es una z, valor 26, base 6.662463E-18, contribucion 0.00000000000000017322404593976733826
Es una z, valor 26, base 2.467579E-19, contribucion 0.00000000000000000641570540517656808
Es una z, valor 26, base 9.139181E-21, contribucion 0.00000000000000000023761871871024326
Es una z, valor 26, base 3.384882E-22, contribucion 0.00000000000000000000880069328556457
Es una z, valor 26, base 1.253660E-23, contribucion 0.00000000000000000000032595160316906
Es una z, valor 26, base 4.643185E-25, contribucion 0.00000000000000000000001207228159885
Es una z, valor 26, base 1.719698E-26, contribucion 0.00000000000000000000000044712154070
Es una z, valor 26, base 6.369253E-28, contribucion 0.00000000000000000000000001656005706
Es una z, valor 26, base 2.358982E-29, contribucion 0.00000000000000000000000000061333545
Es una z, valor 26, base 8.736972E-31, contribucion 0.00000000000000000000000000002271613
Es una z, valor 26, base 3.235916E-32, contribucion 0.00000000000000000000000000000084134
Es una z, valor 26, base 1.198487E-33, contribucion 0.00000000000000000000000000000003116
Es una z, valor 26, base 4.438842E-35, contribucion 0.00000000000000000000000000000000115
Es una z, valor 26, base 1.644015E-36, contribucion 0.00000000000000000000000000000000004
0.999999999999999999837369674127
Observad cómo a partir del 19º dígito significativo lo que queda es ruido de redondeo. Lo vemos ya en la primera conversión en lo que debería ser z = 0.962 periódico. Y eso se refleja en el resultado final, lo que debería ser 0.9 periódico sólo lo es hasta cierto punto. Como nuestro alfabeto es de 26+1 elementos (26 letras más la @), necesitamos 1.431 dígitos por carácter. Con los 18 dígitos decimales que nos da la precisión double podemos representar 19/1.431 = 12.57 caracteres. Una muestra:
$echo aaaaaaaaaaaaa | pal2num |
Es una a, valor 1, base 3.703704E-02, contribucion 0.037037037037037037
Es una a, valor 1, base 1.371742E-03, contribucion 0.001371742112482853
Es una a, valor 1, base 5.080526E-05, contribucion 0.000050805263425291
Es una a, valor 1, base 1.881676E-06, contribucion 0.000001881676423159
Es una a, valor 1, base 6.969172E-08, contribucion 0.000000069691719376
Es una a, valor 1, base 2.581175E-09, contribucion 0.000000002581174792
Es una a, valor 1, base 9.559907E-11, contribucion 0.000000000095599066
Es una a, valor 1, base 3.540706E-12, contribucion 0.000000000003540706
Es una a, valor 1, base 1.311373E-13, contribucion 0.000000000000131137
Es una a, valor 1, base 4.856936E-15, contribucion 0.000000000000004857
Es una a, valor 1, base 1.798865E-16, contribucion 0.000000000000000180
Es una a, valor 1, base 6.662463E-18, contribucion 0.000000000000000007
Es una a, valor 1, base 2.467579E-19, contribucion 0.000000000000000000 No contribuye
0.038461538461538462
El 13er carácter no aparecerá a menos que sea muy alto, porque son 12.57 caracteres, no 12 exactos. De nuevo el haber incluido un carácter extra en el conjunto nos ayuda a mitigar el efecto del error acumulado en las operaciones. Como la aplicación deja de ser suprayectiva, ese hueco en el dominio imagen introduce un rango de valores que no provienen de una palabra válida, son los que van desde ‘@a’ a ‘@zzzzz…’.
Esta entrada es de por sí muy abstracta. Así que para no hacerme pesado prefiero continuar con una segunda parte. Os dejo los archivos por si alguno quiere hacer pruebas. Están en este enlace y podéis encontrar:
- pal2num.pl: Es el archivo en Perl que sirve para hacer el estudio. Admite datos por la entrada estándar. Si se le pasa una palabra devuelve su correspondiente número decimal (con la precisión adaptada y el redondeo adecuado a la longitud como he explicado antes). Si se le pasa un número devuelve la palabra origen, si existe.
- pal2num.c: Versión simplificada del anterior. Optimizada para hacer conversiones masivas rápidamente.
- pal2num: Binario compilado de pal2num.c.
- dominio_espectral.pl: Dado un texto separa sus palabras, y dibuja un gráfico con el espectro de frecuencias. Lo usaremos en la siguiente entrada.
- Directorio textos: Varios textos para probar. Versiones de El Quijote en español, francés e inglés. Y diccionarios de inglés y español obtenidos de OpenOffice.
- Directorio imágenes: Algunos gráficos generados por dominio_espectral.pl.