segunda-feira, 25 de novembro de 2024

Ó IA, traduz isto ...

A Torre de Babel
Bruegel o Velho
c. 1563
 

Neste artigo proponho-me relatar a experiência na utilização de uma ferramenta de IA, no caso o Copilot (via Bing), na tradução de um pedaço de código Python para Erlang.

Desde logo convém realçar o desafio, que não se trata apenas de uma tradução gramatical entre duas linguagens mas, mais do que isso, uma tradução semântica entre linguagens de paradigmas totalmente distintos, pelo menos na utilização que aqui lhes dou:


Introdução


Imutabilidade


Esta é uma característica típica de linguagens funcionais (desde logo a Erlang mas também Haskell, Scala, F#, Clojure) e que permite, por exemplo, eliminar do horizonte muitos efeitos secundários perniciosos que advêm da partilha de memória (conteúdos de variáveis) em sistemas com concorrência.

Ao primeiro contacto, apresenta-se como uma limitação ao programador que venha de uma linguagem, normalmente procedimental ou orientada a objectos, em que o conteúdo de uma variável pode ser livremente alterado.

Estruturas como ciclos e acumulações, parecem impossíveis de resolver. 

Um exemplo de um algoritmo trivial de cálculo do valor médio de um vector de números em C.

#include <stdio.h>

int soma(int vector[], int size) {
    int total = 0;
    // ALERTA MUTABILIDADE i++
    for(int i=0; i<size; i++) { //
        // ALERTA MUTABILIDADE total +=
        total += vector[i];
    }
    return total;
}

double media(int vector[], int size) {
    return soma(vector, size) / (size + 0.0);
}

int main()
{
    int valores[] = {10, 12, 15, 30, 50, 12, 45};
    printf("Media: %f", media(valores, 7));
    return 0;
}

Media: 24.857143

...Program finished with exit code 0
Press ENTER to exit console.

Ou uma versão equivalente em Python, um pouco menos mutável mas não totalmente:

def soma(vector):
    total = 0
    for valor in vector:
        # ALERTA MUTABILIDADE total +=
        total += valor
    return total

def media(vector):
    return soma(vector) / len(vector)

vector = [10, 12, 15, 30, 50, 12, 45]

print(media(vector))

PS .\Source\Python\ProjectEuler\media> python.exe .\media.py  
24.857142857142858

Perante problemas deste tipo, as linguagens funcionais com imutabilidade de variáveis fazem uso, extensivo, da recursividade.

Exemplificando o mesmo problema em Erlang, mas antes alguma semântica básica introdutória para os menos versados.

[H|T] - Lista em que H , head, cabeça, é o primeiro elemento da lista e T uma lista com os elementos seguintes.

[_|T] - Lista em que não queremos saber do primeiro elemento para nada.

[H|[]] - Lista composta por um elemento, no caso H sendo o resto a lista vazia [].


Pattern matching


Em Erlang, uma função pode ter várias "diferentes implementações" sendo a sua selecção e utilização dependente dos argumentos fornecidos.

Cada um dos casos será inspeccionado e o primeiro em que os argumentos fornecidos correspondam à definição será utilizado.

Exemplo:

len([_|[]], Acc) será utilizada sempre que a lista fornecida tenha apenas um elemento, caso contrário será utilizada len([_|T], Acc).

-module(media).

-export([soma/2, len/2, media/1, run/1]).

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%%%%%% Tamanho de uma lista %%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

% caso base
len([_|[]], Acc) ->
    Acc + 1;
% caso geral
len([_|T], Acc) ->
    % recursão extraindo o primeiro valor da lista
    len(T, Acc + 1).

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%% Soma de uma lista %%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

% caso base
soma([H|[]], Acc) ->
    H + Acc;
% caso geral
soma([H|T], Acc) ->
    % recursão usando e extraindo o primeiro valor da lista
    soma(T, H + Acc).

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%% Média dos valores  de uma lista %%%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

media(Vector) ->
    soma(Vector, 0) / len(Vector, 0).

run(Vector) ->
    io:format("Media: ~w~n", [media(Vector)]).


Erlang/OTP 26 [erts-14.1.1] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns]

Eshell V14.1.1 (press Ctrl+G to abort, type help(). for help)
1> c(media).                                                                                                                                                    
{ok,media}
2> media:run([10, 12, 15, 30, 50, 12, 45]).                                                                                                                    
Media: 24.857142857142858
ok
3>


O caso


Quem gosta de linguagens de programação (e de programar) está sempre à procura de desafios, e um local salvífico do tédio do programador é o Project Euler (vão ver).

Um problema que num daqueles momentos resolvi tentar resolver é o problema 31 e consiste em calcular quantas maneiras diferentes há de perfazer um determinado valor monetário tendo em conta os tipos de moedas e notas disponíveis.

No problema é dado o exemplo inglês mas pode, sem alteração, ser usado em Portugal com euros: completar 2 euros com moedas de 1, 2, 5, 10, 20 e 50 cêntimos e de 1 e 2 euros.

Segundo o portal, o problema foi solucionado 92203 vezes e é de dificuldade elementar (5%).

Há soluções em todas as linguagens, algumas lindíssimas e curiosíssimas, e eu tentei também a minha sorte com Pyhton.

Cá está ela:

import math

def calc(target, coins):
    if len(coins) == 1:
        return 1 if target % coins[0] == 0 else 0

    total = 0
    for i in range(len(coins)):
        c = math.floor(target/coins[i])
        while c > 0:
            remaining = target - c * coins[i]
            if remaining == 0:
                total += 1
            elif remaining > 0:
                total += calc(remaining, coins[i+1:])
            c -= 1
    return total

coins = [200,100,50,20,10,5,2,1]

print(calc(200, coins))

O algoritmo é básico, poderá certamente ser melhorado, was not my point, mas despacha-se razoavelmente bem até um valor de teste de "bom senso". :)

Por exemplo:
  • 1 cêntimo (1 maneira)
  • 2 cêntimos (2 maneiras)
  • 10 cêntimos (11)
  • 2 euros (73682)
  • 3 euros (471363)
  • 5 euros (6295434) <- 5 segundos
  • 6 euros (16960860) <- 11 segundos
E não vale a pena alargar mais o leque que o tempo é exponencial.


A "funcionalização" do algoritmo


Foi então que resolvi reescrever o mesmo algoritmo em Erlang, e com se ouve dizer que um das ajudas que a IA podia dar na programação é a de, precisamente, traduzir linguagens de programação, passei a palavra ao Copilot no Bing.

Solicito educadamente "Podes traduzir este pedaço de código Python para Erlang?" e replico o código fonte que mostrei acima.

Responde quase imediatamente, embora não pareça totalmente confiante: "Este código Erlang deve replicar a lógica do código Python que forneceste. Se tiveres alguma dúvida ou precisares de mais alguma coisa, estou aqui para ajudar!"

-module(coin_calc).
-export([calc/2]).

calc(Target, Coins) ->
    case length(Coins) of
        1 ->
            if Target rem hd(Coins) =:= 0 -> 1;
               true -> 0
            end;
        _ ->
            calc(Target, Coins, 0)
    end.

calc(_, [], Total) -> Total;
calc(Target, [Coin | Rest], Total) ->
    C = Target div Coin,
    calc(Target, Coin, Rest, Total, C).

calc(_, _, _, Total, 0) -> Total;
calc(Target, Coin, Rest, Total, C) ->
    Remaining = Target - C * Coin,
    NewTotal = if Remaining =:= 0 -> Total + 1;
                  Remaining > 0 -> Total + calc(Remaining, Rest);
                  true -> Total
               end,
    calc(Target, Coin, Rest, NewTotal, C - 1).

Vamos lá experimentar:

Erlang/OTP 26 [erts-14.1.1] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit:ns]

Eshell V14.1.1 (press Ctrl+G to abort, type help(). for help)
1> c(coin_calc).
{ok,coin_calc}
2> coin_calc:calc(200,[200,100,50,20,10,5,2,1]).
1
3>

Hmm! 1?

Voltou ao meu amigo: "Infelizmente não está a funcionar correctamente, devolve sempre o valor 1." ao que me responde solícito: "Entendi, vamos tentar corrigir isso. Aqui está uma versão ajustada do código Erlang. Nesta versão, adicionei uma função calc_loop para iterar sobre as combinações de moedas e ajustar a contagem total. Tente este código e veja se ele resolve o problema. Se precisar de mais alguma coisa, estou aqui para ajudar!"

-module(coin_calc).
-export([calc/2]).

calc(Target, Coins) ->
    calc(Target, Coins, 0).

calc(0, _, Total) ->
    Total + 1;
calc(_, [], Total) ->
    Total;
calc(Target, [Coin | Rest], Total) ->
    C = Target div Coin,
    calc_loop(Target, Coin, Rest, Total, C).

calc_loop(_, _, _, Total, 0) ->
    Total;
calc_loop(Target, Coin, Rest, Total, C) ->
    Remaining = Target - C * Coin,
    NewTotal = if Remaining =:= 0 -> Total + 1;
                  Remaining > 0 -> Total + calc(Remaining, Rest);
                  true -> Total
               end,
    calc_loop(Target, Coin, Rest, NewTotal, C - 1).


Não resolveu.

Insisti: "Mesmo resultado errado; sempre 1." ao que me respondeu com "Vamos tentar uma abordagem diferente para garantir que a lógica está correta. Aqui está uma versão revisada do código Erlang:" mas com o código exactamente igual ao anteriormente fornecido.

Fiz uma última tentativa, dando uma ajuda: "Não. O código é exactamente igual ao anterior. Parece-me que falta uma chamada recursiva algures." mas sem resultado.

Repetiu o código e eu deixei-o em paz.

Posso não estar a fazer as perguntas certas e pode o Copilot via Bing não ser ferramenta adequada (testei no ChatGPT 3.5 com resultados erradamente semelhantes; se alguém ler isto e quiser testar noutra plataforma, dê notícias, obrigado). 

Algo ressalta, a meu ver, desta experiência.


Conclusão


A tradução efectuada está errada (abaixo apresento uma versão corrigida) e pude confirmar que estava errada apenas porque o resultado era óbvia e evidentemente errado: 1, sempre.

Imaginemos agora que o resultado era errado mas não desta forma gritante, só "ligeiramente". Que hipótese teria eu de o validar? Usar o código Python, que sei correcto, para validar respostas da versão Erlang? Imaginemos outro tipo de problemas com entradas e respostas mais complexas.

É impraticável.

E depois de confirmado o erro, como descobrir onde está o bug?

A menos que se saiba algo, bastante mais do que o trivial de Erlang, dificilmente se será capaz de tornar o código utilizável.


Posfácio


O código corrigido (a  amarelo  a alteração):

-module(coin_calc).
-export([calc/2]).

calc(Target, Coins) ->
    calc(Target, Coins, 0).

calc(0, _, Total) ->
    Total + 1;
calc(_, [], Total) ->
    Total;
calc(Target, [Coin | Rest], Total) ->
    C = Target div Coin,
    calc_loop(Target, Coin, Rest, Total, C).

calc_loop(Target, _, Rest, Total, 0) ->
    Total + calc(Target, Rest, 0);
calc_loop(Target, Coin, Rest, Total, C) ->
    Remaining = Target - C * Coin,
    NewTotal = if Remaining =:= 0 -> Total + 1;
                  Remaining > 0 -> Total + calc(Remaining, Rest);
                  true -> Total
               end,
    calc_loop(Target, Coin, Rest, NewTotal, C - 1).

Curiosidade: tempos de execução em Erlang aproximadamente 90% mais rápidos do que a versão em Python.


quinta-feira, 7 de novembro de 2024

Que língua é esta?


 As frequências relativas de cada uma das letras no conjunto de palavras de uma língua constituem uma característica única dessa língua.

Sabemos, por exemplo que a letra a tem uma frequência relativa de 14.6% no português mas cerca de 6.0% no alemão.

Se conhecermos estas frequências relativas para várias línguas, um vector de percentagens para cada língua, basta-nos calcular o vector de frequências relativas das letras do texto que pretendemos analisar e tentar identificar de qual dos vectores de língua mais se aproxima.

É o que pretendo fazer, sem fazer uso de nenhum tipo de repositório lexical (dicionário).

O exercício será executado em linguagem R mas não tem nada de particular, aconselhado ou desaconselhado, que impeça a sua realização noutra linguagem qualquer.

Frequência de letras de idiomas

O repositório de dados que vai ser utilizado é algo reduzido mas suficiente para o conceito que se pretende demonstrar. Representa 15 idiomas (inglês, francês, alemão, espanhol, português, italiano, turco, sueco, polaco, neerlandês, dinamarquês, islandês, finlandês, checo e húngaro) e pode ser encontrado na Wikipedia no verbete Letter frequency.

A extração da tabela foi feita de forma manual, simples copy/paste

Tabela de frequências relativas

com edição também manual mínima, ou até nenhuma, se a seleção for feita com cuidado.



Embora a tabela inclua todas as letras usadas nos idiomas representados, incluindo caracteres acentuados ou específicos, vão ser usadas apenas as letras de [a-z].

Os dados obtidos assim diretamente da página constituem linhas de valores separados por TAB, pelo que o ficheiro resultante é um ficheiro de texto.tsv (tab separated values).

Note-se que os valores são representados de maneira formatada para leitura, em pontos percentuais e incluindo o caractere %. Alguns valores incluem ainda outro tipo de caracteres não numéricos como '(', ')', '~' e '*', assim como a linha de título inclui algumas indicações de notas de rodapé [dd]; tudo isto será retirado no script de forma automatizada.

O processo

1) Ler o conteúdo do ficheiro

Para tal vai usar-se a package readr (Read Rectangular Text Data) que contém variadas funções de leitura de ficheiros de texto em vários formatos, nomeadamente o tsv.

library(readr) 

df <- readr::read_tsv("data/letter_frequencies.tsv", col_names = TRUE)

O que resulta numa estrutura do tipo data-frame com o seguinte aspecto:

2) Limpeza e normalização de dados

Como já referido, há caracteres que devem ser retirados. Para tal vai usar-se a substituição por expressão regular gsub.

Primeiramente a linha de cabeçalho

colnames(df) <- gsub('([a-zA-Z]*)(.*)', '\\1', colnames(df))


De seguida uma função geral de retirada dos caracteres %, ~, ( e )

clean_values <- function(data) {

    as.numeric(gsub('([0-9\\.]*)([%~\\*\\(\\)])', '\\1', data))

}

aplicada através da função sapply às colunas de valores df[2:ncol(df)]

df <- data.frame(df[1], sapply(df[2:ncol(df)], clean_values))

Após estas operações, o data-frame de frequências relativas por idioma fica com o seguinte aspecto:

3) Analisar o texto de que queremos identificar o idioma

O objectivo aqui é calcular um vector de frequências relativas das letras presentes no texto a analisar.

Começamos então por calcular as frequências absolutas:

  letter_count <- (
    text
    %>% tolower 
    %>% strsplit(split="") 
    %>% unlist 
    %>% `[`(!. %in% c("", " ", ".", ",")) 
    %>% table
    %>& data.frame)

Nota: usamos aqui o operador forward-pipe %>% apenas como curiosidade; é possível executar as mesmas operações usando a idiomática funcional típica. text %>% tolower é equivalente a     tolower(text).

Aplicando então à frase "as armas e os baroes assinalados que da ocidental praia lusitana" resulta no data-frame:


Precisamos de tornar este data-frame comparável com os vectores de frequências dos idiomas pelo que temos de incluir nele também as letras que não fazem parte do texto.

Para tal usamos a função merge que faz a junção de dois vectores (de forma semelhante ao JOIN do SQL) com a opção de incluir todos os valores do vector de letras dos idiomas (uma espécie de LEFT JOIN em SQL).

    merge(x = frequency_df[1], y = letter_count, by = c("Letter"), all.x = TRUE)

com o resultado:


Substituídos os NA por 0

    letter_count[is.na(letter_count)] <- 0

e calculadas as frequências relativas

    letter_count$Percentage <- 100 * letter_count$Frequency /
        sum(letter_count$Frequency)

obtemos o vector de frequências relativas que será usado para comparação com os vectores de idioma; a coluna Percentage do data-frame:


A coluna Percentage é directamente comparável com as colunas de idioma, já que tem o mesmo número de valores e dispostos pela mesma ordem (alfabética das letras respectivas), pelo que podemos calcular a distância entre cada uma destas e aquela e verificar então qual dos idiomas se encontra mais próximo.

Notar que cada um destes vectores de 26 valores contém, nem mais nem menos, do que as coordenadas de um ponto de um hipotético espaço 26-dimensional, pelo que é aplicável qualquer uma das fórmulas típicas de distância.

Desde logo a distância euclidiana:


em que n = 26

ou, a que valos utilizar, a distância de Manhatan:

com a instrução

    t(data.frame(lapply(df[2:ncol(df)], manhattan, letter_count$Percentage)))

em que se verifica que idioma mais próximo é o Português.


4) Teste

Partimos então da frase em português "as armas e os baroes assinalados que da ocidental praia lusitana" a qual traduzimos com o google translator para cada um dos restantes 14 idiomas (sem preocupações poéticas):

    phrase_pt <- "as armas e os baroes assinalados que da ocidental praia lusitana"
    phrase_en <- "the weapons and barons marked from the western Lusitanian beach"
    phrase_tk <- "Batı Lusitanya sahilinden işaretlenmiş silahlar ve baronlar"
    phrase_fr <- "les armes et les barons marquaient celui de la plage ouest de la Lusitanienne"
    phrase_de <- "Die Waffen und Barone markierten das vom westlichen lusitanischen Strand aus"
    phrase_es <- "las armas y los barones marcaron eso desde la playa lusitana occidental"
    phrase_it <- "le armi e i baroni lo segnavano dalla spiaggia lusitana occidentale"
    phrase_se <- "vapnen och baronerna markerade det från den västra lusitanska stranden"
    phrase_pl <- "broń i baronowie oznaczali to z zachodniej plaży Lusitanii"
    phrase_nl <- "de wapens en baronnen markeerden dat vanaf het westelijke Lusitaanse strand"
    phrase_dk <- "våbnene og baronerne påpegede, at der fra den vestlige lusitanske"
    phrase_is <- "vopnin og barónarnir merktu það frá vesturhluta Lusitanian ströndinni"
    phrase_fi <- "aseet ja paronit merkitsivät sen Länsi-Lusitanian rannalta"
    phrase_ck <- "Zbraně a baroni označili,To ze západní pláže Lusitana"
    phrase_hu <- "A fegyverek és a bárók megjelölték, A nyugati Lusitana strandé"

agrupadas numa estrutura apropriada:

    test_df <- data.frame(
      rbind(
        c("English", phrase_en), 
        c("French", phrase_fr), 
        c("German", phrase_de), 
        c("Spanish", phrase_es), 
        c("Portuguese", phrase_pt), 
        c("Italian", phrase_it), 
        c("Turkish", phrase_tk), 
        c("Swedish", phrase_se), 
        c("Polish", phrase_pl), 
        c("Dutch", phrase_nl), 
        c("Danish", phrase_dk), 
        c("Icelandic", phrase_is), 
        c("Finnish", phrase_fi), 
        c("Czech", phrase_ck), 
        c("Hungarian", phrase_hu)))

    colnames(test_df) <- c("Idiom", "SampleText")

e encapsulamos numa função as operações referidas acima:

idiom_inference <- function(text, frequency_df, distance_fn) {

  # getting a data frame of the absolute frequencies of individual letters
  # present on `text`
  letter_count <- (
    text
    %>% tolower
    %>% strsplit(split="") 
    %>% unlist 
    %>% `[`(!. %in% c("", " ", ".", ",")) 
    %>% table
    %>% data.frame)

  # giving the letters data frame a proper nice structure
  colnames(letter_count) = c("Letter", "Frequency")

  # and merging it with the original all-letters columns
  # in order to get a complete vector
  letter_count <- merge(x = frequency_df[1], y = letter_count, by = c("Letter"), all.x = TRUE)

  # letter missing in `text` get a NA value so we transform that in 0
  letter_count[is.na(letter_count)] <- 0

  # and calculate the relative frequency
  letter_count$Percentage <- 100 * letter_count$Frequency / sum(letter_count$Frequency)

  # finally we apply the distance function between out text relative frequency vector
  # to each of the idioms relative frequency
  distances <- t(data.frame(lapply(frequency_df[2:ncol(frequency_df)], distance_fn, letter_count$Percentage)))

  # and return then rowname, which is the idiom name, of the lesser
  rownames(distances)[which(distances == min(distances), arr.ind=TRUE)[,1]]
}

que se executa com:

    infered <- lapply(X=test_df$SampleText, FUN=idiom_inference, df, abdiv::manhattan)

ontem os seguintes resultados:

5) Conclusões

A eficiência não é total já que falhou dois idiomas, "confundindo" dinamarquês com holandês e checo com italiano.

Para tal poderá ter contribuído a relativamente reduzida dimensão do texto bem como a não consideração de caracteres acentuados ou específicos em alguns idiomas, como é precisamente o caso do dinamarquês e do checo embora não se tenha confundido no húngaro em que tal também se verifica.

Não sabemos também como se comportará com outro tipo de textos, mais longos ou mais curtos, pelo que há espaço para evolução.

Espero que tenham gostado do exercício e parece que já estou a ouvir: "em Python faz-se em 3 linhas".

Venham elas.

6) Código fonte

O projecto encontra-se no GitHub

#c-memes