domingo, 20 de março de 2022

Gaussian Mixture Models - Parte III

No último post implementamos um algoritmo EM para estimar os parâmetros do modelo de mistura de duas distribuições Gaussianas. Hoje vamos ver o modelo de mistura com $m$ distribuições Gaussianas.

Modelo

Seja $X$ a variável de interesse. Considere que os dados possam ser originados de $m$ distribuições Gaussianas. A função de densidade do modelo é dada por

\begin{eqnarray} f(x) = \sum_{j=1}^m p_j f(x|\theta_j), \nonumber \end{eqnarray}

em que $\theta_j = (\mu_j,\sigma^2_j)$ e $p_j$ são os parâmetros e o peso da $j$-ésima distribuição, respectivamente. Considerando $Y_i$ como a variável que indica a que distribuição $X_i$ pertence e dada uma amostra aleatória, a função de log-verossimilhança do modelo completo será

\begin{eqnarray} l(\theta) = \log \prod_{i=1}^n f(x_i,y_i) = \sum_{i=1}^n \sum_{y_i \in C} 1\{y_i = c\} \big[ \log(p_{y_i}) + \log(f(x_i|\theta_{y_i})) \big], \nonumber \end{eqnarray}

em que $C = \{1,2,...,m\}$.

Algoritmo EM

Passo E

A função $Q$ será dada por

\begin{eqnarray} Q(\theta|\theta^{(k)}) = \sum_{i=1}^n \sum_{y_i \in C} \gamma_{ic} \big[ \log(p_{y_i}) + \log(f(x_i|\theta_{y_i})) \big], \nonumber \end{eqnarray}

em que

\begin{eqnarray} \gamma_{ic} = E[1\{y_i = c\}|x_i,\theta^{(k)}] = P(Y_i = c | x_i) = \dfrac{p_c f(x_i|\theta_c)}{\sum_{j=1}^m p_j f(x_i|\theta_j)}. \nonumber \end{eqnarray}

Passo M

Maximizando a função $Q$, encontramos os seguintes estimadores para os parâmetros:

\begin{eqnarray} \hat{\mu}_j &=& \dfrac{\sum_{i=1}^n (\gamma_{ij}x_i)}{\sum_{i=1}^n \gamma_{ij}}, \nonumber \\ \hat{\sigma}^2_j &=& \dfrac{\sum_{i=1}^n \gamma_{ij}(x_i-\mu_i)^2}{\sum_{i=1}^n \gamma_{ij}}, \nonumber \\ \hat{p}_j &=& (1-\sum_{k=1,k\neq j}^{m-1}p_k)\dfrac{\sum_{i=1}^n \gamma_{ij}}{\sum_{i=1}^n (\gamma_{ij}+\gamma_{im})}, \nonumber \\ \hat{p}_m &=& 1 - \hat{p}_1 - \hat{p}_2 -...-\hat{p}_{m-1}. \nonumber \end{eqnarray}

Implementando o algoritmo

Agora, vamos implementar um algoritmo que recebe do usuário os dados, o número de distribuições Gaussianas que será assumido, o vetor de valores iniciais para os parâmetros, o número máximo de iterações e o critério de parada do algoritmo EM (diferença máxima permitida entre duas estimações). Para isso, vamos assumir que o vetor de parâmetros possui o seguinte padrão: $\theta = (\mu_1,...,\mu_m,\sigma^2_1,...,\sigma^2_m,p_1,...,p_{m-1})$. Perceba que o tamanho do vetor $\theta$ deve ser $3m-1$.

Abaixo mostro como fica o algoritmo. Como fizemos passo a passo a implementação para duas distribuições no post anterior, explicarei apenas as principais mudanças.

set.seed(7)
GMM_EM = function(x,m,theta_ini,max_ite=100,dif_parada=10^-3){
  
  npar = length(theta_ini);
  
  n = length(x)
  if(length(theta_ini)!=(3*m-1)){
    cat("Tamanho do vetor de valores iniciais deve ser 3m -1, em que m é o 
        número de distribuições consideradas.\n");
    return();
  }
  
  gamaic = matrix(nrow=n,ncol=m);
  
  theta.curr = theta_ini;
  theta.trace = matrix(nrow=1,ncol=length(theta.curr));
  theta.trace[1,] = theta.curr;
  mu.hat = matrix(nrow=1,ncol=m);
  sigma2.hat = matrix(nrow=1,ncol=m);
  p.hat = matrix(nrow=1,ncol=(m-1));
  
  indm = 1;
  inds = m+1;
  indp = 2*m+1;
  
  atingiu_maxit = TRUE;
  parada = matrix(0,ncol=npar,nrow=1); #menor que o erro
  
  for(cont in 1:max_ite){
    #PASSO E - CALCULO DE GAMMAIC
    
    pm = 1 - sum(theta.curr[indp:(3*m-1)])
    for(i in 1:n){
      d = matrix(nrow=1,ncol=m);
      for(j in 0:(m-2)){
        d[1,(j+1)] = theta.curr[indp+j]*dnorm(x[i],mean=theta.curr[indm+j],
                                              sd=sqrt(theta.curr[inds+j]));
      }
      d[1,m] = pm*dnorm(x[i],mean=theta.curr[m],sd=sqrt(theta.curr[2*m]));
      denom = sum(d);
      for(j in 1:m) gamaic[i,j] = d[1,j]/denom;
    }
    
    #PASSO M - ESTIMACAO
    for(j in 1:m){
      sg = sum(gamaic[,j]);
      mu.hat[1,j] = sum(gamaic[,j]*x)/sg;
      delta = (x-mu.hat[1,j])^2;
      sigma2.hat[1,j] = sum(gamaic[,j]*delta)/sg;
    }
    sgm = sum(gamaic[,m]);
    aux = theta.curr[indp:(3*m-1)];
    for(j in 1:(m-1)){
      sp = 1 - sum(aux[-j]);
      sg = sum(gamaic[,j]);
      p.hat[1,j] = sp* sg/(sg+sgm);
    } 
    
    theta.curr = c(mu.hat[1,],sigma2.hat[1,],p.hat[1,]);
    theta.trace = rbind(theta.trace,theta.curr);
    
    dif = abs(theta.trace[cont+1,]-theta.trace[cont,])
    for(j in 1:npar) {
      if(dif[j] < dif_parada) {
        parada[j] = 1;
      }else{
        parada[j] = 0;
      }
    }
    
    if(sum(parada)==npar) {
      cat("Algoritmo convergiu com",cont,"iterações.\n")
      atingiu_maxit=FALSE;
      break;
    }
    
  }
  
  if(atingiu_maxit==T) cat("Algoritmo atingiu número máximo de iterações.\n")
  
  return(theta.trace);
}

Vamos às principais alterações:

  • Verificamos se o tamanho do vetor de valores iniciais corresponde ao número de distribuições informado.
  if(length(theta_ini)!=(3*m-1)){
    cat("Tamanho do vetor de valores iniciais deve ser 3m -1, em que m é o 
        número de distribuições consideradas.\n");
    return();
  }
  • Como os parâmetros estão em apenas um vetor (theta.curr), armazenamos as posições do vetor em que cada conjunto de parâmetros começa, isto é, os parâmetros de média começam em [1], os de variância em [m+1] e os de peso em [2*m+1].
  indm = 1;
  inds = m+1;
  indp = 2*m+1;
  • Os passos E e M condizem com as fórmulas do começo do post. Deixo para você interpretá-las.
  • A parada do algoritmo ocorre quando a diferença da estimação de cada parâmetro entre a iteração atual e a anterior é menor que o critério fornecido (dif_parada).
    dif = abs(theta.trace[cont+1,]-theta.trace[cont,])
    print(round(dif,4));
    for(j in 1:npar) {
      if(dif[j] < dif_parada) {
        parada[j] = 1;
      }else{
        parada[j] = 0;
      }
    }
    
    if(sum(parada)==npar) {
      cat("Algoritmo convergiu com",cont,"iterações.\n")
      atingiu_maxit=FALSE;
      break;
    }

Vamos testar o algoritmo acima com os mesmos dados do post anterior: duas distribuições.

set.seed(7)
x = c(rnorm(50,mean=-2,sd=1),rnorm(50,mean=2,sd=1))

theta_ini = c(-1,1,10,1,.2);
max_ite = 100;
m=2;

> res = GMM_EM(x,m,theta_ini,max_ite,dif_parada = 10^-4);
Algoritmo convergiu com 20 iterações.

Perceba que o algoritmo convergiu em um número de iterações bem menor que o máximo fornecido. Utilizar critério de parada pode nos ajudar a poupar tempo, tanto computacional quanto nosso próprio tempo. Lembre-se disso!

O gráfico abaixo apresenta a trajetória das estimativas dos parâmetros. Como utilizamos os mesmos valores iniciais do post anterior, obtemos o mesmo gráfico. Porém, agora com menos iterações devido ao critério de parada. 


Note que, nesse caso, sabemos exatamente de quantas distribuições os dados foram originados. Suponha agora que não sabemos e informamos o número "errado" de distribuições. O que aconteceria? Vamos testar:

theta_ini = c(-1,1,5,10,1,3,.5,.1);
max_ite = 500;
m=3;

> res = GMM_EM(x,m,theta_ini,max_ite,dif_parada = 10^-4);
Algoritmo convergiu com 488 iterações.

Observe que, dessa vez, o algoritmo demorou mais para convergir. O resultado é apresentado abaixo:



> res[489,] #mu1 mu2 mu3 sigma2.1 sigma2.2 sigma2.3 p1 p2
[1] -1.99194446  1.74776537  2.45300571  0.61822279  1.20592365  0.06526058
[7]  0.44328369  0.48841668
> 1-res[489,7]-res[489,8] #p3
theta.curr 
0.06829962 

Note que, apesar de o algoritmo encontrar uma terceira distribuição com média 2.45, o peso dessa distribuição é bem próximo de zero. Isso indica que, apesar de assumirmos que há três distribuições, apenas duas são relevantes com mais de 90% de peso. Essas duas distribuições são bem próximas das que geramos os dados, ou seja, temos uma boa estimativa mesmo considerando três distribuições.

Agora temos um algoritmo para estimar os parâmetros do modelo de mistura de normais para um número genérico de distribuições. Incrível, não?

Espero que tenha gostado da aula.

Até a próxima aula!

quinta-feira, 17 de março de 2022

Gaussian Mixture Models - Parte II

No post anterior, vimos o modelo de mistura de distribuições normais e fizemos um exemplo considerando duas distribuições Gaussianas. Vimos também que não há forma fechada para os EMVs dos parâmetros do modelo e recorremos ao modelo aumentado, incorporando uma variável latente para indicar de qual distribuição uma determinada observação é originada. Nesse contexto, utilizamos o algoritmo EM para estimar os parâmetros do modelo.

No post de hoje, vamos implementar o algoritmo de estimação para nosso exemplo utilizando o R. Como motivação, vamos simular dados de duas distribuições Gaussianas, a primeira com média -2 e a segunda com média 2, ambas com variância 1. A figura abaixo apresenta o histograma de 100 observações, sendo metade dos dados de cada distribuição.

set.seed(7)
x = c(rnorm(50,mean=-2,sd=1),rnorm(50,mean=2,sd=1))
par(mar=c(2,4,.5,.5))
hist(x,main='',xlab='')



Para começar nosso código, vamos definir alguns objetos.

n = length(x)

gamaic = matrix(nrow=n,ncol=2);

#theta = (mu1,mu2,sigma2.1,sigma2.2,p1)
theta.curr = c(1,1,10,1,.2)
theta.trace = matrix(nrow=1,ncol=length(theta.curr));
theta.trace[1,] = theta.curr

max_ite = 100;

O que fizemos:

  1. Computamos o tamanho da amostra,
  2. Criamos uma matriz para conter todos os valores de $\gamma_{ic} = P(Y_i = c|x_i)$.
  3. Computamos valores iniciais para o vetor de parâmetros $(\mu_1,\mu_2,\sigma^2_1,\sigma^2_2,p_1)$.
  4. Criamos uma matriz para armazenar a trajetória dos parâmetros.
  5. Colocamos os valores iniciais na primeira linha da matriz de trajetórias.
  6. Definimos o número máximo de iterações do algoritmo como 100.

Nosso algoritmo é iterativo, logo deve estar dentro de uma estrutura de repetição:

for(j in 1:max_ite){
  (...)
}

Vamos começar a programar nosso método de estimação. Primeiro vamos armazenar os valores de theta.curr em variáveis com os nomes dos parâmetros para o código ficar mais legível (mas não é necessário).

mu1 = theta.curr[1];
mu2 = theta.curr[2];
sigma2.1 = theta.curr[3];
sigma2.2 = theta.curr[4];
p1 = theta.curr[5];
p2 = 1-p1;

Agora, vamos implementar o passo E. Nesse passo, temos que calcular os valores de $\gamma_{ic}$ dados os valores de $x$ e $\theta^{(j-1)}$.

#PASSO E - CALCULO DE GAMMAIC

for(i in 1:n){
  d1 = p1*dnorm(x[i],mean=mu1,sd=sqrt(sigma2.1));
  d2 = p2*dnorm(x[i],mean=mu2,sd=sqrt(sigma2.2));

  gamaic[i,1] = d1/(d1+d2);
  gamaic[i,2] = d2/(d1+d2);
}

Agora, dados os valores de $\gamma_{ic}$, vamos maximizar a função $Q$ e estimar os parâmetros (passo M), calculando as estimativas dos parâmetros.

#PASSO M - ESTIMACAO

sg1 = sum(gamaic[,1]);
sg2 = sum(gamaic[,2]);

mu1.hat = sum(gamaic[,1]*x)/sg1;
mu2.hat = sum(gamaic[,2]*x)/sg2;
delta = (x-mu1.hat)^2;
sigma2.1.hat = sum(gamaic[,1]*delta)/sg1;
delta = (x-mu2.hat)^2;
sigma2.2.hat = sum(gamaic[,2]*delta)/sg2;
p1.hat = sg1/(sg1+sg2);

Com as novas estimativas dos parâmetros, precisamos atualizar o vetor theta.curr e armazenar essas estimativas na matriz de trajetórias.

theta.curr = c(mu1.hat,mu2.hat,sigma2.1.hat,sigma2.2.hat,p1.hat)
theta.trace = rbind(theta.trace,theta.curr);

O algoritmo está pronto. Mas temos que executar todo o bloco de código cada vez que quisermos realizar a estimação. Dessa forma, o código abaixo apresenta o algoritmo dentro de uma função.

GMM_EM = function(x,theta_ini,max_ite){
    
    n = length(x)
    
    gamaic = matrix(nrow=n,ncol=2);
    
    theta.curr = theta_ini;
    theta.trace = matrix(nrow=1,ncol=length(theta.curr));
    theta.trace[1,] = theta.curr
    
    for(j in 1:max_ite){
    
        mu1 = theta.curr[1];
        mu2 = theta.curr[2];
        sigma2.1 = theta.curr[3];
        sigma2.2 = theta.curr[4];
        p1 = theta.curr[5];
        p2 = 1-p1;
        
        #PASSO E - CALCULO DE GAMMAIC
        
        for(i in 1:n){
          d1 = p1*dnorm(x[i],mean=mu1,sd=sqrt(sigma2.1));
          d2 = p2*dnorm(x[i],mean=mu2,sd=sqrt(sigma2.2));
        
          gamaic[i,1] = d1/(d1+d2);
          gamaic[i,2] = d2/(d1+d2);
        }
        
        #PASSO M - ESTIMACAO
        
        sg1 = sum(gamaic[,1]);
        sg2 = sum(gamaic[,2]);
        
        mu1.hat = sum(gamaic[,1]*x)/sg1;
        mu2.hat = sum(gamaic[,2]*x)/sg2;
        delta = (x-mu1.hat)^2;
        sigma2.1.hat = sum(gamaic[,1]*delta)/sg1;
        delta = (x-mu2.hat)^2;
        sigma2.2.hat = sum(gamaic[,2]*delta)/sg2;
        p1.hat = sg1/(sg1+sg2);

        theta.curr = c(mu1.hat,mu2.hat,sigma2.1.hat,sigma2.2.hat,p1.hat)
        theta.trace = rbind(theta.trace,theta.curr);
    }
    return(theta.trace);
}

Vamos testar nosso código com os dados que geramos inicialmente.

set.seed(7)
x = c(rnorm(50,mean=-2,sd=1),rnorm(50,mean=2,sd=1))

theta_ini = c(1,1,10,1,.2);
max_ite = 100;
res = GMM_EM(x,theta_ini,max_ite);

>head(res);
                [,1]     [,2]      [,3]     [,4]      [,5]
            1.000000 1.000000 10.000000 1.000000 0.2000000
theta.curr -1.139293 1.070248  4.817979 2.227314 0.4216040
theta.curr -1.194942 1.261527  3.356893 2.722751 0.4570911
theta.curr -1.313885 1.418818  2.698869 2.608024 0.4684452
theta.curr -1.477107 1.584125  2.149425 2.196990 0.4721721
theta.curr -1.655599 1.751241  1.567991 1.660437 0.4733255

Agora vamos plotar os gráficos com as trajetórias das estimativas dos parâmetros.

par(mfrow=c(2,3))
plot(res[,1],type='l',main=expression(mu[1])); abline(h=-2,col='blue');
plot(res[,2],type='l',main=expression(mu[2])); abline(h=2,col='blue');
plot(res[,3],type='l',main=expression(sigma[1]^2 )); abline(h=1,col='blue');
plot(res[,4],type='l',main=expression(sigma[2]^2 )); abline(h=1,col='blue');
plot(res[,5],type='l',main=expression(p[1])); abline(h=0.5,col='blue');



O valor da estimativa dos parâmetros é a última linha da matriz de trajetórias, ou seja, a última estimativa fornecida pelo algoritmo. Dessa forma, temos

>res[(max_ite+1),] #mu1 mu2 sigma2.1 sigma2.2 p1
[1] -1.9704849  1.8669399  0.6421497  1.0473874  0.4503654

>1 - res[(max_ite+1),5]  #p2
0.5496346

Agora temos um algoritmo para estimar os parâmetros do modelo de misturas de duas distribuições Gaussianas! Incrível, não acha?

Porém, há um problema que precisamos discutir. O que aconteceria se assumíssemos valores iniciais iguais para os parâmetros das duas distribuições? Vamos testar.

theta_ini = c(1,1,1,1,.2); 
max_ite = 100;
res = GMM_EM(x,theta_ini,max_ite);

> head(res);
                [,1]      [,2]     [,3]     [,4] [,5]
           1.0000000 1.0000000 1.000000 1.000000  0.2
theta.curr 0.1386966 0.1386966 4.510061 4.510061  0.2
theta.curr 0.1386966 0.1386966 4.510061 4.510061  0.2
theta.curr 0.1386966 0.1386966 4.510061 4.510061  0.2
theta.curr 0.1386966 0.1386966 4.510061 4.510061  0.2
theta.curr 0.1386966 0.1386966 4.510061 4.510061  0.2

Observe que a estimativa de $p_1$ não se altera e, após a primeira iteração, as estimativas dos demais parâmetros permanecem constantes. Por que isso ocorre? Observe que assumimos $\mu_1^{(0)} = \mu_2^{(0)}$ e $(\sigma^2_1)^{(0)} = (\sigma^2_2)^{(0)}$ e ,como consequência, $f(x_i|\theta_1) = f(x_i|\theta_2)$. Dessa forma, $\gamma_{i1} = p_1^{(0)}$ e $\gamma_{i2} = p_2^{(0)}$ e, como consequência,

\begin{eqnarray} \hat{\mu}_1 = \hat{\mu}_2 &=& \dfrac{\sum_{i-1}^{n} x_i}{n}, \nonumber\\ \hat{\sigma}^2_1 = \hat{\sigma}^2_2 &=& \dfrac{\sum_{i-1}^{n}(x_i-\hat{\mu}_1)^2}{n}, \nonumber\\ \hat{p}_1 &=& p_1^{(0)}. \nonumber \end{eqnarray}

Logo, para que o algoritmo funcione, precisamos assumir valores iniciais distintos para as distribuições. Se consideramos mesma média, devemos assumir variâncias diferentes e vice-versa.

Na próxima aula vamos expandir a ideia do modelo para uma mistura de $n$ distribuições Gaussianas.

Espero que tenha gostado da aula.

Até a próxima aula!

terça-feira, 15 de março de 2022

Gaussian Mixture Models - Parte I

Normalmente utilizamos uma determinada distribuição para modelar dados. Escolhida uma distribuição para a modelagem, precisamos estimar seus parâmetros por algum método de estimação. Por exemplo, se estamos trabalhando com a distribuição normal, é de interesse estimar os parâmetros $\mu$ e $\sigma^2$, que são a média e a variância da distribuição. Note que, nesse caso, supomos que os dados são originados de uma distribuição apenas.

Mas e se os dados vierem de duas ou mais distribuições? Confuso? Imagine clusters (ou agrupamentos), como na figura abaixo. Percebemos que uma parte dos dados parecem ser originados de uma distribuição e a outra parte, de outra distribuição. Os dados observados (representados pelo histograma) contemplam os dados das duas distribuições mas, na prática, não sabemos quais são (ou quantas são). Dessa forma, a modelagem de mistura de distribuições se torna bastante útil.



Nesse post, ensino como trabalhar com mistura de distribuições Gaussianas, ou seja, quando os dados pertencem a mais de uma distribuição normal (médias e/ou variâncias distintas). Para a estimação dos parâmetros, utilizaremos o algoritmo EM (já explicado aqui no blog).

Gaussian Mixture Models

Para começarmos, vamos supor que nossos dados sejam provenientes de duas distribuições Gaussianas com parâmetros desconhecidos. Seja $X$ nossa variável de interesse. A função de densidade do modelo é dada por

$$\begin{eqnarray}\label{eq:model} f(x|\theta) = p_1f(x|\theta_1) + p_2f(x|\theta_2) \end{eqnarray}$$

em que $\theta_1 = (\mu_1,\sigma^2_1)$ e $\theta_2 = (\mu_2,\sigma^2_2)$ são os vetores de parâmetros das distribuições normais 1 e 2, respectivamente, e $p_1$ e $p_2$ são os pesos relacionados a ambas distribuições. Podemos interpretar os pesos como as probabilidades de uma observação ser proveniente de uma determinada distribuição, ou seja, $p_1$ é a probabilidade dessa observação pertencer à distribuição 1 e $p_2$, a probabilidade dela pertencer à distribuição 2. O vetor de parâmetros do modelo é $\theta = (\theta_1,\theta_2,p_1)$. Por que apenas $p_1$? Porque $p_2$ é completamente determinada por $p_1$, isto é, $p_2 = 1 - p_1$.

Seja $X_1, X_2, ..., X_n$ uma amostra aleatória da distribuição em ($\ref{eq:model}$). A função de log-verossimilhança do modelo é dada por

$$\begin{eqnarray} l(\theta) &=& \log \left[ \prod_{i=1}^n f(x_i|\theta) \right] = \sum_{i=1}^n \log f(x_i|\theta) = \sum_{i=1}^n \log \big[ p_1 f(x_i|\theta_1) + p_2 f(x_2|\theta_2) \big]\end{eqnarray}$$

Se prosseguirmos para encontrar os estimadores de máxima verossimilhança (EMV) a partir da função acima, veremos que não há forma fechada para os estimadores. Logo, outra abordagem é necessária.

Modelo aumentado

Para isso, imagine que para cada observação $x_i$ conhecemos a qual distribuição ela pertence. Isso é equivalente a dizer que conhecemos uma variável $Y_i$ que indica se a observação $x_i$ pertence a distribuição $C$, $C = 1,2$. A função de densidade de $Y_i$ é dada por

$$f(y_i) = \prod_{y_i \in C} p_{y_i}^{1 \{y_i = c\}}, \quad C = \{1,2\}$$

em que $1\{\}$ é a função indicadora. Dado o valor de $y_i$, a função de densidade de $X_i$ será

$$f(x_i|y_i) = \prod_{y_i \in C} f(x_i|\theta_{y_i})^{1\{y_i = c\}}$$

Na prática não observamos os valores de $Y_i$. Dessa forma, $Y_i$ é uma variável latente e trabalhamos com o modelo aumentado $(X,Y)$. A função de densidade conjunta de $(X_i,Y_i)$ é dada por

\begin{eqnarray}
f(x_i,y_i) &=& f(x_i|y_i) f(y_i)  = \prod_{y_i \in C} \left( p_{y_i} f(x_i|\theta_{y_i}) \right)^{1\{y_i = c\}} \nonumber
\end{eqnarray}

A função de verossimilhança do modelo aumentado, dada uma amostra aleatória $(X,Y) = ((X_1,Y_1),...,(X_n,Y_n))$, será 

\begin{eqnarray}
L(\theta) &=& \prod_{i=1}^n f(x_i|y_i) f(y_i)  = \prod_{i=1}^n \left[ \prod_{y_i \in C} \left( p_{y_i} f(x_i|\theta_{y_i}) \right)^{1\{y_i = c\}} \right] \nonumber
\end{eqnarray}

e a função de log-verossimilhança é dada por 

\begin{eqnarray}
l(\theta) &=& \sum_{i=1}^n  \sum_{y_i \in C} {1\{y_i = c\}} \left[ \log (p_{y_i}) + \log (f(x_i|\theta_{y_i}))  \right]  \nonumber
\end{eqnarray}

Vamos refletir um pouco sobre o que nós fizemos. 

  • Primeiro modelamos o problema e chegamos na função de verossimilhança em (\ref{eq:model}).
  • Percebemos que não há como estimar os parâmetros do modelo dessa forma e outra abordagem se tornou necessária.
  • Consideramos uma variável $Y$ para indicar em que grupo (cluster) a variável $X$ pertence.
  • Escrevemos o modelo completo para $(X,Y)$.

Observe que $X$ é variável observada, enquanto que $Y$ é não observada (latente). Dessa forma, precisamos de um método para estimar os parâmetros do modelo nesse contexto. Como falamos no começo do post, utilizaremos o algoritmo EM. Mas por quê? Se você acompanha o blog, já deve saber a resposta.

Temos um modelo com variáveis observadas e variáveis não observadas. O algoritmo EM é excelente no contexto de dados faltantes, em que a função de verossimilhança completa é mais simples de se trabalhar do que a função de verossimilhança dos dados observados. Dessa forma, vamos prosseguir para a estimação dos parâmetros.

Estimação dos parâmetros - Algoritmo EM

Passo E

A função $Q$ será dada por

\begin{eqnarray}
Q(\theta|\theta^{(k)}) &=& \sum_{i=1}^n  \sum_{y_i \in C} E[{1\{y_i = c\}}|x,\theta_{(k)}] \left[ \log (p_{y_i}) + \log (f(x_i|\theta_{y_i}))  \right]  \nonumber
\end{eqnarray}

Para facilitar a notação, escrevemos $\gamma_{ic} =  E[{1\{y_i = c\}}|x,\theta_{(k)}] $. Note que $\gamma_{ic}$ é a probabilidade de $Y_i$ ser igual a $c$ (ou seja, indicar que $x_i$ pertence a distribuição $c$) dado o valor de $x_i$. Ou seja, 

\begin{eqnarray} \gamma_{ic} = P(Y_i = c|x_i) = f(y_i = c|x_i) \nonumber \end{eqnarray}

Para calcularmos o valor de $\gamma_{ic}$, precisamos encontrar a distribuição de $Y_i|X_i$. Temos então

\begin{eqnarray}
f(y_i|x_i) &=& \dfrac{f(x_i|y_i)f(y_i)}{f(x_i)} = \dfrac{\prod_{y_i \in C} \left[p_{y_i}f(x_i|\theta_{y_i}) \right] ^{1\{y_i = c\}}}{p_{1}f(x_i|\theta_{1})+p_{2}f(x_i|\theta_{2})}  \nonumber
\end{eqnarray}

Dessa forma,

\begin{eqnarray} \gamma_{ic} &=& P(Y_i = c|x_i)  = \dfrac{p_{c}f(x_i|\theta_{c}) }{p_{1}f(x_i|\theta_{1})+p_{2}f(x_i|\theta_{2})} \nonumber \end{eqnarray}

Passo M

Para encontrar os estimadores, precisamos derivar a função $Q()$ em relação aos parâmetros de interesse. Vamos realizar primeiro a estimação de $\mu_1$:

\begin{eqnarray}
\dfrac{d}{d\mu_1}Q()  &=&\sum_{i=1}^{n} \gamma_{i1}  \dfrac{d}{d\mu_1} \log f(x_i|\theta_1) = \sum_{i=1}{n} \gamma_{i1}  \dfrac{(x_i-\mu_1)}{\sigma^2_1} \nonumber
\end{eqnarray}

Igualando $(d/d\mu_1)Q()$ a zero, obtemos $\hat{\mu}_1 = \dfrac{\sum_{i=1}^n (\gamma_{i1} x_i)}{\sum_{i=1}^n \gamma_{i1}}$.

De forma análoga, $\hat{\mu}_2 = \dfrac{\sum_{i=1}^n (\gamma_{i2} x_i)}{\sum_{i=1}^n \gamma_{i2}}$

Em relação à variância da distribuição 1, $\sigma^2_1$, temos

\begin{eqnarray}
\dfrac{d}{d\sigma^2_1}Q()  &=&\sum_{i=1}{n} \gamma_{i1}  \left(\dfrac{-1}{2\sigma^2_1} + \dfrac{1}{2\sigma^4_1}(x_i-\mu_1)^2 \right)\nonumber \\
\end{eqnarray}

Igualando $(d/d\sigma^2_1)Q()$ a zero, obtemos $\hat{\sigma}^2_1 = \dfrac{\sum_{i=1}^n \gamma_{i1}(x_i-\mu_1)^2 }{\sum_{i=1}^n \gamma_{i1}}$.

De forma análoga, $\hat{\sigma}^2_2 = \dfrac{\sum_{i=1}^n \gamma_{i2}(x_i-\mu_2)^2 }{\sum_{i=1}^n \gamma_{i2}}$.

Em relação aos pesos $p_1$ e $p_2$, temos que lembrar que

$$p_1 + p_2 = 1 \rightarrow p_2 = 1 - p_1$$

Dessa forma, vamos encontrar o estimador de $p_1$.

\begin{eqnarray}
\dfrac{d}{d p_1}Q()  &=&\sum_{i=1}^{n} \left( \gamma_{i1}  \dfrac{1}{p_1} - \gamma_{i2} \dfrac{1}{1-p_1} \right) \nonumber 
\end{eqnarray}

Igualando $(d/dp_1)Q()$ a zero, obtemos $\hat{p}_1 = \dfrac{\sum_{i=1}^n \gamma_{i1} }{\sum_{i=1}^n (\gamma_{i1}+\gamma_{i2})}$.

Logo, o estimador para $p_2$ é $\hat{p}_2 = 1 - \hat{p}_1$.

Dessa forma, encontramos uma metodologia para estimar os parâmetros da distribuição de mistura de normais. A implementação deste exemplo será feita no próximo post.

Espero que tenha gostado da aula.

Até a próxima aula!

sexta-feira, 4 de março de 2022

Algoritmo EM - Parte III

No post anterior, falamos da necessidade de utilizar vários valores iniciais no algoritmo EM para verificar se a convergência se dá na mesma região. Hoje vamos programar este teste.

Para fazer isso, vamos fazer algumas alterações no código anterior:

  1. Nossa função vai receber o argumento adicional dif_parada, que é o critério de parada do algoritmo (ou a diferença máxima permitida entre duas estimações).
  2. Vamos criar uma variável para identificar se o algoritmo parou porque convergiu (ou seja, a diferença entre duas estimações consecutivas foi menor que dif_parada) ou se atingiu o limite máximo de iterações.
  3. Em cada um dos dois casos, uma mensagem deve ser exibida para o usuário.
O código alterado você pode conferir abaixo.
algoritmoEM_tStudent = function(par_ini,max_ite,y,nu,plot.trace=0,dif_parada=10^-4){
  theta.curr = c(par_ini[1],par_ini[2]);
  n = length(y);
  
  theta.trace = matrix(ncol=2,nrow=1);
  theta.trace[1,] = theta.curr;
  atingiu_maxit = TRUE;
  for(i in 1:max_ite){
    #Passo 1 do EM - calcular E(U|Y)
    
    alfa = 0.5*(n + nu)
    beta = 0.5*(nu + sum((y-theta.curr[1])^2)/theta.curr[2]);
    u.y = alfa/beta;
    
    #Passo 2 do EM - Maximizar a função Q
    # em relação aos parâmetros
    
    mu.hat = sum(y)/n;
    sigma2.hat = u.y*(sum((y-mu.hat)^2)/n);
    
    theta.curr = c(mu.hat,sigma2.hat);
    theta.trace = rbind(theta.trace,theta.curr);
    
    dif = abs(theta.trace[i+1,]-theta.trace[i,])

    if(dif[1]&lt dif_parada & dif[2]&lt dif_parada) {
      cat("Algoritmo convergiu com",i,"iterações.\n")
      atingiu_maxit=FALSE;
      break;
    }
    
  }
  
  if(atingiu_maxit==T) cat("Algoritmo atingiu número máximo de iterações.\n")
  
  if(plot.trace==1){
    par(mfrow=c(1,2))
    plot(theta.trace[,1],main=expression(mu),ylab='',
         xlab='Iteração',type='l',lwd=2)
    
    plot(theta.trace[,2],main=expression(sigma^2),ylab='',
         xlab='Iteração',type='l',lwd=2)
  }
  
  return(theta.trace)
}

Antes o programa parava a execução depois que todas as iterações fossem executadas. Agora, se o algoritmo convergir antes, paramos sua execução e ganhamos tempo computacional e memória. Ou seja, estamos evitando realizar trabalho desnecessário.

Para testar se o algoritmo está convergindo para uma única determinada região, precisamos executá-lo a partir de vários valores iniciais. Porém, fazer cada um na mão pode ser trabalhoso, então vamos automatizar esse processo.

Primeiro, vamos criar um vetor de valores iniciais para os parâmetros $\mu$ e $\sigma^2$.

vi_mu = c(-2,-1,0,1,2)
vi_sigma2 = c(0.1,0.5,1,2,5)

Para cada par de valor inicial $(\mu^{(0)},\sigma^{2(0)})$, vamos executar nosso algoritmo e retornar a estimação dos parâmetros. Para armazenar os resultados da estimação, utilizaremos uma lista.

erro_max = 10^-4;
max_ite = 100;

result = list();
for(i in 1:length(vi_mu)){
  result[[i]] = algoritmoEM_tStudent(c(vi_mu[i],vi_sigma2[i]),max_ite,y,nu,0,erro_max)
}

No código acima:

  • Definimos o critério de parada como $10^4$.
  • Definimos o número máximo de iterações igual a 100.
  • Definimos a estrutura de dados que vai receber os resultados: a lista result.
  • Para cada par de valores iniciais, executamos o algoritmo EM e armazenamos seu respectivo resultado em result.

Se executarmos o código, result será uma lista de tamanho 5 contendo os resultados da estimação de cada par de valores iniciais dos parâmetros.

> result = list();
> for(i in 1:length(vi_mu)){
+   result[[i]] = algoritmoEM_tStudent(c(vi_mu[i],vi_sigma2[i]),max_ite,y,nu,0,erro_max)
+ }
Algoritmo atingiu número máximo de iterações.
Algoritmo atingiu número máximo de iterações.
Algoritmo convergiu com 99 iterações.
Algoritmo convergiu com 91 iterações.
Algoritmo convergiu com 77 iterações.

> length(result)
[1] 5

Observe que o algoritmo atingiu o número máximo de iterações em 2 pares e convergiu antes para os restantes. Isso exemplifica que, dependendo do valor inicial, a convergência pode ser mais lenta, principalmente se os valores iniciais estiverem distantes da região de convergência.

Agora, precisamos analisar se a convergência se dá na mesma região. Para isso, vamos plotar as linhas de trajetórias das cinco estimações em um único gráfico. Para obtermos um bom gráfico, necessitamos de duas coisas:

  1. calcular os valores mínimo e máximo de estimações (caso contrário, algumas curvas podem aparecer parcialmente se o eixo y não for bem definido),
  2. identificar o número máximo de iterações dos resultados para definir até onde plotar a curva no eixo x.

Vejamos:

#encontrar os limites do eixo y para ambos parametros
#e o número máximo de iterações executado
min_mu = max_mu = min_sigma2 = max_sigma2 = NULL;
ite_res = NULL;
for(i in 1:length(result)){
  min_mu = min(min_mu,result[[i]][,1]);
  max_mu = max(max_mu,result[[i]][,1]);
  min_sigma2 = min(min_sigma2,result[[i]][,2]);
  max_sigma2 = max(max_sigma2,result[[i]][,2]);
  ite_res = max(ite_res,dim(result[[i]])[1]);
}

O que estamos fazendo no código acima é varrer cada matriz de result, identificando os valores máximo e mínimo das estimações dos parâmetros. Se executarmos o código acima, obtemos

> cbind(min_mu,max_mu);
     min_mu max_mu
[1,]     -2      2
> cbind(min_sigma2,max_sigma2);
     min_sigma2 max_sigma2
[1,] 0.01044391          5
> ite_res
[1] 101

Obtemos ite_res igual a 101 porque são 100 iterações do algoritmo mais a linha do valor inicial. Agora estamos prontos para construir o gráfico de trajetórias.

par(mfrow=c(2,1))

#Grafico para mu
plot(result[[1]][,1],type='l',main=expression(mu),
     ylab='',xlab='Iteração',lwd=1,
     xlim=c(0,ite_res),ylim=c(min_mu,max_mu))
abline(h=mu,col='blue',lwd=2)
abline(h=emv_mu,col='red',lwd=2)
for(i in 2:length(result))  lines(result[[i]][,1],lwd=1)


#Grafico para sigma2
plot(result[[1]][,2],type='l',main=expression(sigma^2),
     ylab='',xlab='Iteração',lwd=1,
     xlim=c(0,ite_res),ylim=c(min_sigma2,max_sigma2))
abline(h=sigma2,col='blue',lwd=2)
abline(h=emv_sigma2,col='red',lwd=2)
for(i in 2:length(result))  lines(result[[i]][,2],lwd=1)

Vejamos o resultado:


A linha azul representa o valor verdadeiro do parâmetro e a linha vermelha, o valor de seu EMV. Como a estimação de $\mu$ converge depois de uma iteração (discutimos isso no post anterior), nesse gráfico fica ruim de observar a convergência. Vamos alterar a janela do eixo x para xlim=c(0,5), apenas para observarmos o comportamento da saída do valor inicial para a estimação. No gráfico de $\sigma^2$ é mais fácil visualizar que o algoritmo está convergindo para o mesmo lugar (o EMV).

E se quisermos rodar o algoritmo para 20 valores iniciais? Ou 100? Seria bom gerar valores iniciais ao invés de escrevê-los a mão, concorda comigo? É o que fazemos abaixo:

set.seed(7)

vi_mu = rnorm(20,mean=0,sd=2)
vi_sigma2 = rgamma(20,shape=2,rate=2)

Dessa forma, geramos 20 valores iniciais para os parâmetros. Veja que para $\sigma^2$ estamos gerando de uma distribuição Gama, uma vez que seu valor deve ser positivo. Poderíamos utilizar outras distribuições? Claro que sim.

Se executarmos o algoritmo EM com os valores iniciais acima, obtemos:


Alterei o limite máximo do eixo y do gráfico de $\sigma^2$ apenas para termos uma melhor visualização das curvas. Analisemos o gráfico acima:

  • No gráfico de $\mu$, observamos a convergência para o mesmo ponto (EMV) após 1 iteração do algoritmo.
  • No gráfico de $\sigma^2$, notamos que a convergência para a mesma região (EMV) partindo dos diferentes valores iniciais. Observe que há curvas que não alcançaram o EMV. Isso ocorre porque o algoritmo atingiu o limite de iterações que definimos.
Portanto, vamos executar novamente o algoritmo com 2000 iterações. Observe o gráfico abaixo. Agora conseguimos visualizar todas as curvas convergindo para o EMV, pois permitimos iterações suficientes para o algoritmo convergir. Além disse, o número máximo de iterações executados foi 183, bem menor do que o máximo que definimos. Isso ilustra a vantagem de verificar convergência.


Já analisamos o efeito do número de iterações máxima no resultado do algoritmo. Agora, qual o efeito de alterar o critério de parada do algoritmo? Ou seja, o que acontece se alteramos o critério que definimos para considerar convergência? Vamos fazer o teste com o valor de erro_max igual a $10^5$. O resultado é o "mesmo" que o anterior, pois as estimativas são as mesmas. A diferença é que o algoritmo demorou mais para convergir, ou seja, precisou de mais iterações para conferir o critério de convergência. No caso, obtemos um número máximo de iterações executadas igual a 230.


Por fim, faremos o mesmo exemplo, mas com um tamanho de amostra igual a 5 mil (utilize a mesma semente do exemplo anterior). Nesse caso, os valores do EMV dos parâmetros são mais próximos do valor verdadeiro (pois temos mais informação).

> emv_mu
[1] 0.003103879
> emv_sigma2
[1] 0.9086786

Executando nossa função para os 20 valores iniciais, 10 mil iterações e erro igual a $10^5$, obtemos o seguinte gráfico de trajetória para $\sigma^2$.


A partir do critério de parada estabelecido, o algoritmo convergiu antes de 10 mil iterações para todas as 20 execuções. Note que precisamos de muito mais iterações com essa amostra. Isso ilustra a necessidade de adequar o erro e o número máximo de iterações para cada problema que se tem em mãos. A análise gráfica ajuda muito nessa etapa.

Espero que tenha gostado da aula.

Até a próxima aula!

quarta-feira, 2 de março de 2022

Algoritmo EM - Parte II

No post anterior apresentei o algoritmo EM e expliquei como o algoritmo funciona. Também apresentei um exemplo em que calculamos as quantidades necessárias para sua execução. Vamos relembrar esse exemplo.

Suponha que temos uma amostra aleatória de tamanho $n$ de uma população com distribuição t-Student com $\nu$ graus de liberdade. Nosso interesse é estimar os parâmetros de média $\mu$ e de escala $\sigma^2$. Seja $Y$ nossa variável de interesse. Escrevemos a distribuição de $Y$ na forma hierárquica

$$\begin{eqnarray}Y|U &\sim& N(\mu,u^{-1}\sigma^2), \nonumber \\ U &\sim& Gamma(\nu/2,\nu/2). \nonumber \end{eqnarray}$$

Vimos no post anterior que precisamos da quantidade $E[U|Y,\theta]$ e a calculamos:

$$E_{U|Y} = \dfrac{(n-\nu)\sigma^2}{\nu\sigma^2 + \sum_{i=1}^{n} (y_i- \mu)^2}.$$

Também encontramos os estimadores dos parâmetros ao maximizar a função $Q$, obtendo

$$\begin{eqnarray} \hat{\mu} &=& \dfrac{\sum_{i=1}^{n} y_i}{n}. \nonumber \\ \hat{\sigma}^2 &=& E_{U|Y} \dfrac{\sum_{i=1}^{n} (y_i- \mu)^2}{n}. \nonumber \end{eqnarray}$$

Lembrando que o primeiro passo - E - é calcular o valor esperado da variável latente dados os valores observados $y$ e os valores dos parâmetros na $k$-ésima iteração, e o segundo passo - M - é maximizar a função $Q$ dados os valores observados e o valor esperado da variável latente para obter os valores dos parâmetros da iteração $k+1$. Os passos são repetidos até que se observe convergência ou um número máximo de iterações seja atingido.

IMPORTANTE RELEMBRAR: os estimadores EM convergem para os estimadores de máxima verossimilhança (EMV).

Agora que relembramos as quantidades que serão necessárias, vamos implementar o algoritmo EM para nosso exemplo. Vamos começar gerando nossos dados:

n  = 100;
nu = 5;
mu = 0;
sigma2 = 1;

set.seed(7);
u = rgamma(1,shape=n/2,rate=n/2);
y = rnorm(1000,mean = 0, sd = sqrt(sigma2)*(u^-1));

Estamos gerando $n=100$ observações de uma distribuição t-Student com 5 graus de liberdade, com média igual a 0 e escala igual a 1. Para visualizar os dados, computamos seu histograma, por exemplo.

hist(y,main='')

Qual a estimativa dos parâmetros pelos EMVs?

emv_mu = sum(y)/n 
emv_sigma2 = sum((y-emv_mu)^2)/n

> emv_mu
[1] 0.09044438
> emv_sigma2
[1] 0.4832229

Observe que a estimativa da média é um pouco maior que zero e que, apesar de assumirmos que $\sigma^2 =1$, temos que $\hat{\sigma}^2 = 0,48$, menos que a metade do valor original. Isso é possível pois estamos trabalhando com uma amostra e essa foi a informação que a amostra nos forneceu.

Vamos partir para o algoritmo EM. O começo do código:

theta.curr = c(1,2);
max_ite = 100;

theta.trace = matrix(ncol=2,nrow=1);
theta.trace[1,] = theta.curr;

O que estamos fazendo?

  1. theta.curr armazenará os valores correntes do vetor de parâmetros. Iniciamos com $\mu=3$ e $\sigma^2=5$.
  2. Definimos o número máximo de iterações como 100.
  3. theta.trace é onde armazenaremos os valores estimados dos parâmetros em cada iteração do algoritmo.
  4. O primeiro valor armazenado em theta.trace são os valores iniciais. 

Ao final do algoritmo, faremos um plot para visualizar as estimativas de cada parâmetro, observando visualmente se o algoritmo convergiu ou não. Essa é uma maneira de observar convergência, mas não a melhor. Discutiremos isso mais adiante.

A seguir, construímos o algoritmo propriamente dito. Os passos E e M são realizados até o número de iterações atingir o número máximo. Portanto

for(i in 1:max_ite){
  #Passo 1 do EM - calcular E(U|Y)
  
  alfa = 0.5*(n + nu)
  beta = 0.5*(nu + sum((y-theta.curr[1])^2)/sigma2);
  u.y = alfa/beta;
  
  #Passo 2 do EM - Maximizar a função Q
  # em relação aos parâmetros
  
  mu.hat = sum(y)/n;
  sigma2.hat = u.y*(sum((y-mu.hat)^2)/n);
  
  theta.curr = c(mu.hat,sigma2.hat);
  theta.trace = rbind(theta.trace,theta.curr);
}

O que estamos fazendo?

  1. Calculamos o valor de $E_{U|Y}$ - passo E.
  2. Calculamos os valores de $\hat{\mu}^{(k+1)}$ e $(\hat{\sigma}^2)^{(k+1)}$ - passo M.
  3. Atualizamos o vetor theta.curr com as novas estimativas dos parâmetros.
  4. Armazenamos essas estimativas em theta.trace.
  5. Esse bloco é repetido até que i seja igual a max_ite.

Se executarmos o código acima, obtemos:

> head(theta.trace)
                 [,1]      [,2]
           1.00000000 2.0000000
theta.curr 0.09044438 0.7194313
theta.curr 0.09044438 0.7030660
theta.curr 0.09044438 0.6881575
theta.curr 0.09044438 0.6745351
theta.curr 0.09044438 0.6620535

Observe que a estimativa da média não mudou após a primeira iteração. Isso ocorre porque seu estimador não depende do valor de $u$ nem da estimativa de $\sigma^2$. Em relação ao parâmetro de escala, perceba que temos uma diferença grande nas estimativas nas iterações iniciais e essa diferença diminui. É melhor visualizar isso em um gráfico de trajetória, construído com estimativa versus iteração.

par(mar=c(4,2,2,2),mfrow=c(1,2))
plot(theta.trace[,1],main=expression(mu),ylab='',
     xlab='Iteração',type='l',ylim=c(-0.5,1),lwd=2)
abline(h=mu,col='blue',lwd=2)

plot(theta.trace[,2],main=expression(sigma^2),ylab='',
     xlab='Iteração',type='l',lwd=2)
abline(h=sigma2,col='blue',lwd=2)


Nos gráficos acima, a linha azul representa o valor verdadeiro do parâmetro e a linha vermelha, o valor do EMV. Então perceba que as estimativas pelo algoritmo EM estão convergindo para os valores do EMV.

Simples, não? É claro que a programação do método não é tão simples para modelos mais complexos.

Você pode estar se perguntando: por que os estimadores do EM convergem para os valores do EMV e não para os valores verdadeiros? A resposta é bem teórica, mas vou resumir a ideia. O EM é uma ferramenta utilizada para estimar os parâmetros do modelo em um modelo aumentado onde há dados faltantes. A verossimilhança incompleta (ou observada) é obtida integrando a verossimilhança completa em relação às variáveis latentes. Dessa forma, os estimadores do EM convergem para os EMVs. Como os EMVs são não viciados para os valores dos parâmetros, então os estimadores do EM convergem para os parâmetros da população. Essa é só a ideia, se quiser aprofundar sobre o assunto aconselho estudar as provas matemáticas do método.

É importante executar o algoritmo EM com diferentes valores iniciais para os parâmetros para verificar se a convergência se dá na mesma região. Caso haja diferença da região de convergência a partir de valores iniciais distintos, pode significar que o algoritmo está convergindo para máximos locais e não para o máximo global. Ou seja, sempre faça essa verificação.

Para terminar, vamos organizar nosso código e colocá-lo em uma função. Dessa forma, não precisaremos executar o bloco de código do for toda vez que quisermos rodar o algoritmo. Além disso, vamos

  1. implementar um critério de parada, para que não seja necessário chegar ao máximo de iterações se o algoritmo convergir antes,
  2. plotar as trajetórias das estimativas se o usuário quiser.

O código da função fica como:

algoritmoEM_tStudent = function(par_ini,max_ite,y,nu,plot.trace=0){
  theta.curr = c(par_ini[1],par_ini[2]);
  n = length(y);
  
  theta.trace = matrix(ncol=2,nrow=1);
  theta.trace[1,] = theta.curr;
  dif_parada = 10^-4;
  for(i in 1:max_ite){
    #Passo 1 do EM - calcular E(U|Y)
    
    alfa = 0.5*(n + nu)
    beta = 0.5*(nu + sum((y-theta.curr[1])^2)/theta.curr[2]);
    u.y = alfa/beta;
    
    #Passo 2 do EM - Maximizar a função Q
    # em relação aos parâmetros
    
    mu.hat = sum(y)/n;
    sigma2.hat = u.y*(sum((y-mu.hat)^2)/n);
    
    theta.curr = c(mu.hat,sigma2.hat);
    theta.trace = rbind(theta.trace,theta.curr);

    dif = abs(theta.trace[i+1,]-theta.trace[i,])

    if(dif[1]<dif_parada & dif[2]<dif_parada) {
      cat("Algoritmo convergiu com",i,"iterações.\n")
      break;
    }
    
  }
  
  if(plot.trace==1){
    par(mfrow=c(1,2))
    plot(theta.trace[,1],main=expression(mu),ylab='',
         xlab='Iteração',type='l',lwd=2)
    
    plot(theta.trace[,2],main=expression(sigma^2),ylab='',
         xlab='Iteração',type='l',lwd=2)
  }
  
  return(theta.trace)
}

1: Para dizer que o algoritmo convergiu, verificamos se a diferença das estimativas de cada parâmetro da iteração atual para anterior é menor que 0,0001. Caso isso ocorra, paramos o algoritmo e evitamos iterações desnecessárias. Obviamente, se alterarmos o valor do critério de parada, alteramos os números de iterações para o mesmo problema.

2: Caso o usuário passe 1 no parâmetro plot.trace, após a execução do EM um gráfico de trajetória para cada parâmetro é construído.

3: Ao final do algoritmo, retornamos theta.trace, que contém todas as estimativas dos parâmetros.

Vamos testar nossa função.

> res = algoritmoEM_tStudent(c(1,2),100,y,nu,1)
Algoritmo convergiu com 91 iterações.

Armazenamos theta.trace (objeto retornado pela nossa função) em res. Os atributos passados são os mesmos do começo do post, então devemos ter os mesmos gráficos de trajetórias. Observe que o algoritmo parou com 91 iterações, antes do número máximo definido. Pense que, se tivéssemos atribuído um número máximo de 1000 iterações, esse critério de parada nos pouparia muito trabalho (e tempo e memória). Por fim, os gráficos plotados são


Perceba que são os mesmos gráficos que construímos anteriormente (sem as personalizações). Agora temos nossa função executando perfeitamente para o nosso problema.

Espero que tenha gostado da aula!

Até a próxima!

segunda-feira, 28 de fevereiro de 2022

Algoritmo EM - Parte I

Hoje vou falar um pouco sobre um algoritmo de estimação bastante conhecido e utilizado quando temos dados faltantes ou variáveis latentes no modelo que estamos trabalhando: o algoritmo EM.

O algoritmo EM foi proposto por Dempster et al. (1977) e é um algoritmo iterativo de estimação de máxima verossimilhança. Este método envolve duas etapas: uma de Esperança (Expectation) e outra de Maximização (Maximization), daí seu nome. A ideia é, no primeiro passo, calcular os valores esperados das variáveis latentes e, no segundo passo, maximizar a função de interesse em relação aos parâmetros do modelo. Essa função é chamada de função Q, como veremos mais adiante.

Como dito, estamos em uma situação em que há dados ausentes, seja por haver missing data ou por modelarmos variáveis latentes para simplificar nosso modelo (o exemplo que darei reflete o segundo caso). Sem perda de generalidade, vamos considerar o caso em que há apenas uma variável latente. Seja $y$ o vetor de valores observados da variável resposta e $u$ a variável latente (ou variável que representa os dados faltantes). Trabalhamos com a função de log-verossimilhança completa dos dados, dada por

$l_c(\theta) = \log f(y,u) = \log f(y|u) + \log(u).$

A função acima seria realmente completa se tivéssemos observado os valores de $u$, mas não temos tais valores. Dessa forma, o primeiro passo do algoritmo EM - passo E - é calcular o valor esperado da função de log-verossimilhança completa dados os valores de $y$ e dos parâmetros de interesse. Para isso, definimos a função $Q$ como

$Q(\theta | \theta^{(k)},y) = E[\log f(y,u)|\theta^{(k)},y].$

Como dito, o algoritmo EM é iterativo, e a função acima calcula a esperança da função de log-verossimilhança completa dados os valores de $y$ e os valores de $\theta$ na $k$-ésima iteração. Ou seja, precisamos dos valores dos parâmetros de interesse para realizar esse passo. Para ficar claro: o algoritmo é iniciado com valores iniciais para os parâmetros.

O segundo passo do algoritmo EM - passo M - é realizado maximizando a função $Q$ em relação aos parâmetros de interesse, isto é,

$\hat{\theta}^{(k+1)} = \text{argmax} Q(\theta | \theta^{(k)},y) .$

Os dois passos são repetidos até que haja convergência na estimação dos parâmetros ou um número máximo de iterações seja atingida.

É importante dizer que os estimadores EM convergem para os estimadores de máxima verossimilhança (EMV). De fato, utilizamos o algoritmo EM quando não há forma fechada para os EMVs do modelo ou quando facilitamos o problema trabalhando com o modelo aumentado (verossimilhança completa).

Isso tudo é bem teórico e acredite quando falo que estou resumindo bastante as coisas aqui. Para tudo que fazemos há fundamentos mas esse blog é voltado para a parte prática, então vou contar que você pesquisará mais sobre o assunto se não é familiarizado com o algoritmo EM. Aliás, não queremos ser apertadores de botão, não é mesmo?

Para entender o método, vamos ver um exemplo simples e didático.

Exemplo: estimação dos parâmetros da distribuição t-Student (com graus de liberdade fixos)

Suponha que temos uma amostra aleatória de tamanho $n$ de uma população com distribuição t-Student com $\nu$ graus de liberdade e suponha que nosso interesse é estimar os parâmetros de média $\mu$ e de escala $\sigma^2$.

Uma opção seria escrever a função de log-verossimilhança da distribuição t e encontrar os estimadores de máxima verossimilhança (EMV). Mas queremos utilizar o algoritmo EM, então vamos escrever a variável $Y$, que segue uma distribuição t, na seguinte forma hierárquica:

$\begin{eqnarray}Y|U &\sim& N(\mu,u^{-1}\sigma^2), \nonumber \\ U &\sim& Gamma(\nu/2,\nu/2). \nonumber \end{eqnarray}$

Note que, marginalmente (em relação a $U$), $Y$ possui uma distribuição t-Student com $\nu$ graus de liberdade.

Bom, agora vamos construir o algoritmo para nosso exemplo. A função de log-verossimilhança completa é dada por

$\begin{eqnarray} l_c(\theta) &\propto& \frac{n}{2} \log(u) - \frac{n}{2} \log(\sigma^2) - \frac{u}{2\sigma^2} \sum_{i=1}^{n} (y_i-\mu)^2 - \frac{\nu}{2} u + \left(\frac{\nu}{2}-1\right) \log(u). \nonumber \end{eqnarray}$

Escrevi "proporcional" por não precisarmos das constantes (em relação aos parâmetros). A função $Q$ será dada por

$\begin{eqnarray} Q(\theta | \theta^{(k)},y) &\propto& \frac{n}{2} E[\log(u)| \theta^{(k)},y] - \frac{n}{2} \log(\sigma^2) - \frac{E[u| \theta^{(k)},y]}{2\sigma^2} \sum_{i=1}^{n} (y_i-\mu)^2 \nonumber\\ && - \frac{\nu}{2} E[u| \theta^{(k)},y] + \left(\frac{\nu}{2}-1\right) E[\log(u)| \theta^{(k)},y]. \nonumber \end{eqnarray}$

O segundo passo é maximizar a função $Q$ em relação aos parâmetros de interesse, no caso, $\mu$ e $\sigma^2$. Para isso, derivamos a função $Q$ em relação ao parâmetro e igualamos a zero para encontrar seu estimador. Note, na função acima, que há termos que são constantes em relação aos parâmetros e, após a derivação, serão anulados. Dessa forma, podemos simplificar ainda mais a função $Q$ mantendo apenas os termos que apresentam $\mu$ e $\sigma^2$, ou seja,

$\begin{eqnarray} Q(\theta | \theta^{(k)},y) &\propto& - \frac{n}{2} \log(\sigma^2) - \frac{E[u| \theta^{(k)},y]}{2\sigma^2} \sum_{i=1}^{n} (y_i-\mu)^2. \nonumber \end{eqnarray}$

Isso nos ajuda (e muito), pois não precisamos calcular a distribuição de $\log(u)| \theta^{(k)},y$ e diminuímos nosso trabalho. Para simplificar a notação, vamos escrever $E[u| \theta^{(k)},y] = E_{U|Y}$.

Agora sim vamos para o segundo passo do algoritmo. Primeiro, vamos encontrar o estimador para a média:

$\begin{eqnarray} \dfrac{d}{d\mu}Q() = \dfrac{E_{U|Y}}{\sigma^2} (\sum_{i=1}^{n} y_i- n\mu).  \nonumber \end{eqnarray}$

Igualando a derivada acima a zero, obtemos

$\begin{eqnarray} \hat{\mu} = \dfrac{\sum_{i=1}^{n} y_i}{n}. \nonumber \end{eqnarray}$

Agora vamos encontrar o estimador para $\sigma^2$:

$\begin{eqnarray} \dfrac{d}{d\sigma^2}Q() = -\dfrac{n}{2\sigma^2} + \dfrac{E_{U|Y}}{2\sigma^4} \sum_{i=1}^{n} (y_i- \mu)^2.  \nonumber \end{eqnarray}$

o que nos leva ao seguinte estimador:

$\begin{eqnarray} \hat{\sigma}^2 = E_{U|Y} \dfrac{\sum_{i=1}^{n} (y_i- \mu)^2}{n}. \nonumber \end{eqnarray}$

Você deve ter notado falta de alguma coisa: como calculamos $E_{U|Y}$? Para isso, vamos descobrir a distribuição de $U|Y$ e, em seguida, calcular seu valor esperado. Note que

$\begin{eqnarray} f(u|y) &\propto& f(y|u)f(u) \nonumber \\ &\propto& (u)^{n/2} \exp \left\{ -\dfrac{u}{2\sigma^2} \sum_{i=1}^{n} (y_i- \mu)^2 \right\} \times \exp\left\{-\dfrac{\nu}{2}u\right\} u^{\nu/2-1} \nonumber \\ &\propto& (u)^{(n+\nu)/2 -1} \exp \left\{ -u\left(\dfrac{\nu}{2} + \dfrac{\sum_{i=1}^{n} (y_i- \mu)^2}{2\sigma^2}\right) \right\}. \nonumber \end{eqnarray}$

A última expressão corresponde ao núcleo de uma distribuição Gamma. Logo,

$$U|Y \sim Gamma \left( \dfrac{1}{2}(n+\nu), \dfrac{1}{2} (\nu + \sum_{i=1}^{n} (y_i- \mu)^2/\sigma^2) \right). $$

O valor esperado de uma distribuição Gamma($\alpha,\beta$) é $\alpha/\beta$. Então,

$$E_{U|Y} = \dfrac{(n-\nu)\sigma^2}{\nu\sigma^2 + \sum_{i=1}^{n} (y_i- \mu)^2}.$$

Agora temos o valor de $E_{U|Y}$ e os estimadores dos parâmetros de interesse. Estamos prontos para executar o algoritmo EM:

  1. Assuma valores iniciais $\hat{\mu}^{(0)}$ e $(\hat{\sigma}^2)^{(0)}$ e um número de iterações máxima.
  2. E: Dados os valores de $y$, $\hat{\mu}^{(k)}$ e $(\hat{\sigma}^2)^{(k)}$, calcule $E_{U|Y}$.
  3. M: Dados os valores de $y$ e $E_{U|Y}$, calcule $\hat{\mu}^{(k+1)}$ e $(\hat{\sigma}^2)^{(k+1)}$.
  4. Se houve convergência ou o número máximo de iterações foi atingida, retorne as estimativas dos parâmetros. Caso contrário, repita os passos 2-3.

Com esse exemplo é fácil ver como o algoritmo EM funciona. Gastamos um tempo para explicar e exemplificar o método para que fique claro seu funcionamento, pois iremos implementá-lo no próximo post. E para programar precisamos entender o que estamos fazendo, não é verdade?

Então espero você na próxima aula!

Referência citada

Dempster, A.P., Laird, N.M., and Rubin, D.B. (1977). Maximum Likelihood from Incomplete Data via the EM Algorithm. Journal of the Royal Statistical Society. Series B, 39(1):1-38.


sexta-feira, 25 de fevereiro de 2022

Calculando determinante de uma matriz pelo Teorema de Laplace - Parte III

Nos posts anteriores, implementamos o teorema de Laplace para calcular o determinante de uma matriz quadrada. Na Parte I construímos a função recursiva e, na Parte II, a função iterativa. Neste post, vamos comparar a eficiência das duas funções que criamos e com a função det embutida no R.

Para isso, vamos utilizar o pacote rbenchmark. Vamos testar nossas funções com uma matriz de ordem 5:

set.seed(7)
M = matrix(sample(1:100,size = 25,replace = F),ncol=5,byrow = T)

> M
     [,1] [,2] [,3] [,4] [,5]
[1,]   42   83   31   92   66
[2,]   15   90    8   67   88
[3,]   40   22   47   93   59
[4,]   12   51   20   94   98
[5,]   79    6   16   89   86

Vamos utilizar a função benchmark e fazer 1000 replicações. Vejamos:

> benchmark(det(M),calc_det(M),calc_det_laplace(M),replications = 1000,
+           columns = c("test", "replications", "elapsed","relative"))
                 test replications elapsed relative
2         calc_det(M)         1000    0.19    6.333
3 calc_det_laplace(M)         1000    0.25    8.333
1              det(M)         1000    0.03    1.000

Veja que a função det é muito mais eficiente que as nossas, o que era esperado, pois as funções do R já são otimizadas.

Agora perceba algo interessante: a função recursiva foi executada mais rápida que a iterativa. Isso ocorreu, provavelmente, pelo fato de construirmos e armazenamos a árvore de matrizes. Se fizermos a conta, o tempo de execução da função iterativa é 1,32 vezes o tempo da função recursiva.

Quanto maior a dimensão da matriz, mais custoso será para calcularmos seu determinante e será mais pesado para nossas funções. Por exemplo, considere a matriz de ordem 10 abaixo:

set.seed(7)
M = matrix(sample(1:100,size = 100,replace = F),ncol=10,byrow = T)

> M
      [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8] [,9] [,10]
 [1,]   42   83   31   92   66   15   90    8   67    88
 [2,]   40   22   47   93   59   12   51   20   94    98
 [3,]   79    6   16   89   86   21    4   11   26     2
 [4,]   38   43   36    3   53   41   96   77   87    45
 [5,]   91   65   81   34   32   85   75   62   24    10
 [6,]   46   82   48   23    5   56   70   97   76    78
 [7,]   64   28  100   18   29   84   30   63   55    80
 [8,]   74   44   17   73   13   61   60   52   37     1
 [9,]   39   54   25   27   68   71    9   35   19    49
[10,]   14   72   58   57   99   69    7   33   95    50

Para essa matriz, temos os seguintes tempos de execução:

> t1 = Sys.time()
> det(M)
[1] 1.197026e+18
> t2 = Sys.time()
> calc_det_laplace(M)
[1] 1.197026e+18
> t3 = Sys.time()
> calc_det(M)
[1] 1.197026e+18
> t4 = Sys.time()

> t2-t1
Time difference of 0.002995014 secs
> t3-t2
Time difference of 10.98335 secs
> t4-t3
Time difference of 6.374352 secs

Perceba que todas as funções apresentaram o mesmo resultado, como era de se esperar. Mas observe o tempo de execução. A função det não chegou a 0,1 segundo de execução, ao passo que nossas funções apresentam tempos bem maiores.

Isso nos mostra que é preferível utilizar as funções já otimizadas do R ao invés de implementá-las. É claro, nossa estratégia de pegar sempre a primeira linha da matriz é ingênua e o código não está otimizado, mas é fato que vale a pena implementar esse tipo de função se for melhor do que o R já disponibiliza.

O intuito com o exercício dessa série foi praticar a programação e utilizamos vários conceitos para isso. Logo, nossa missão foi cumprida.

Espero que tenha gostado da aula.

Até a próxima aula!

quarta-feira, 23 de fevereiro de 2022

Calculando determinante de uma matriz pelo Teorema de Laplace - Parte II

No post anterior (Parte I), implementamos o algoritmo para calcular o determinante de uma matriz via teorema de Laplace (ou método do cofator). No caso, fizemos o método recursivo. Nesse post, faremos o método iterativo.

Para isso, temos que pensar em algumas coisas:

  1. Para calcular o determinante, precisamos calcular cofatores.
  2. Para calcular cofatores, precisamos calcular o determinante das matrizes reduzidas.
  3. Para calcular o determinante das matrizes reduzidas, precisamos calcular os cofatores dessas matrizes...
  4. Até chegarmos nas matrizes reduzidas de ordem 3, que sabemos calcular o determinante de forma direta.

Perceba que, para cada matriz de ordem n, temos n submatrizes de ordem n-1. Para cada matriz de ordem n-1, temos n-1 submatrizes de ordem n-2, e assim por diante. É como se tivéssemos uma árvore de matrizes, cuja base é composta pelas matrizes de ordem 3. A Figura abaixo apresenta um exemplo para uma matriz de ordem 5.


Após calcular o determinante das matrizes de ordem 3, calculamos os determinantes das matrizes de ordem 4, depois das de ordem 5... ou seja, subimos os níveis da árvore de matrizes.

Aqui eu preciso deixar claro que vamos trabalhar com a estrutura de dados de lista e não a de árvore propriamente dita. Porém, continuarei chamando a estrutura de árvore por ser mais fácil de entender a lógica do problema.

Logo, precisamos saber quais são todas essas matrizes. Para isso, vamos implementar uma função que, dada uma matriz de ordem n, calcula as submatrizes de ordem n-1. Assim como no post anterior, para facilitar o problema, vamos escolher sempre a primeira linha da matriz para computar o método do cofator. Vejamos o código:

calc_submatriz = function(A){
  auxsub = A[-1,];
  
  subm = list();
  for(i in 1:ncol(A)) subm[[i]] = auxsub[,-i]
  
  return(subm)
}

O que estamos fazendo no código acima?

  1. Retiramos a primeira linha da matriz;
  2. Iniciamos subm como uma lista para receber as submatrizes.
  3. Para cada coluna da matriz, retiramos a i-ésima coluna e armazenamos em subm.
  4. Retornamos o resultado.

Perceba que calculamos as submatrizes apenas para o próximo nível da árvore. Precisamos calcular todas as submatrizes de ordem inferiores dada uma matriz original. Vamos ao código:

calc_all_submatriz = function(A){
  n = ncol(A);
  if(n<=3){
    cat("Não é necessário calcular as submatrizes.\n")
    return()
  }else{
    res = list()
    res[[1]] = list(ori = A);
    for(i in 2:(n-2)){
      auxres = list();
      auxj = 1;
      for(j in 1:length(res[[i-1]])){
        res_ = calc_submatriz(res[[i-1]][[j]])
        for(w in 1:length(res_)){
          auxres[[auxj]] = res_[[w]]  
          auxj = auxj +1;
        }
      }
      res[[i]] = auxres;
    }
  }
  
  return(res)
}

Vejamos novamente o que estamos fazendo:

  1. Se a dimensão da matriz é igual ou inferior a 3, não é necessário calcular submatrizes.
  2. Caso contrário, iniciamos nossa lista de resultado res.
  3. O primeiro nível da árvore conterá a matriz original.
  4. Para o próximo nível, calculamos as submatrizes da seguinte forma: 
  5. Para cada submatriz do nível anterior, calcule as submatrizes correspondentes.
  6. Armazene essas submatrizes na lista auxres.
  7. Repita os passos 5 e 6 até acabarem as submatrizes do nível anterior.
  8. Armazene auxres na lista de resultado res.
Dessa forma, res será uma lista em que cada elemento dessa lista conterá todas as submatrizes de um nível da árvore. Por exemplo, res[[1]] armazena a matriz original, res[[2]] armazena todas as submatrizes de ordem n-1, res[[3]] armazena todas as submatrizes de ordem n-2 e assim por diante, até res[[n-2]] que armazena todas as matrizes de ordem 3.

Vamos testar nosso código? Vamos listar a árvore de matrizes de uma matriz de ordem 4.

D = matrix(c(1,2,3,4,2,1,3,4,2,1,9,7,4,5,1,4),ncol=4,byrow=T)

calc_all_submatriz(D)

[[1]]
[[1]]$ori
     [,1] [,2] [,3] [,4]
[1,]    1    2    3    4
[2,]    2    1    3    4
[3,]    2    1    9    7
[4,]    4    5    1    4


[[2]]
[[2]][[1]]
     [,1] [,2] [,3]
[1,]    1    3    4
[2,]    1    9    7
[3,]    5    1    4

[[2]][[2]]
     [,1] [,2] [,3]
[1,]    2    3    4
[2,]    2    9    7
[3,]    4    1    4

[[2]][[3]]
     [,1] [,2] [,3]
[1,]    2    1    4
[2,]    2    1    7
[3,]    4    5    4

[[2]][[4]]
     [,1] [,2] [,3]
[1,]    2    1    3
[2,]    2    1    9
[3,]    4    5    1

Você pode verificar que todas as submatrizes de ordem 3 estão especificadas corretamente. Também pode testar com uma matriz de ordem maior, de ordem 5 por exemplo.

Logo, já temos nossa função para calcular todas as submatrizes de uma matriz dada. Agora, precisamos calcular o determinante das matrizes de ordem 3 e depois voltar aos níveis da árvore para calcular os determinantes das matrizes de ordem superior.

Vamos começar definindo a função:

calc_det_laplace = function(A){
  n = ncol(A);
  if(n==2){
    return(calc_det2(A));
  }else if(n==3){
    return(calc_det3(A));
  }
  
  (...)
  
}

Definimos a função e, novamente, se a matriz dada possui ordem 2 ou 3, chamamos a respectiva função para calcular seu determinante de forma direta. Caso a matriz seja de ordem superior a 3, aplicamos o método do cofator.

Vamos continuar com o código da função:

  submat = calc_all_submatriz(A);
  n_levels = length(submat);

No código acima:

  1. Calculamos todas as submatrizes da matriz dada e armazenamos em submat,
  2. Verificamos quantos níveis essa árvore possui.

O próximo passo é calcular o determinante de todas as submatrizes de ordem 3:

  for(i in 1:length(submat[[n_levels]])) auxdet1[i] = calc_det3(submat[[n_levels]][[i]])

Ou seja, estamos acessando o último nível da árvore (lembre que estamos utilizando lista e não a estrutura de dados árvore) e, para cada matriz desse nível, calculando seu determinante.

O próximo passo é, dados os determinantes das matrizes de ordem 3, calcular os determinantes das matrizes de ordem 4, depois das de ordem 5 até chegar na matriz original. Vejamos o código:

  for(i in (n_levels-1):1){
    cofator = NULL;
    dim_mat = ncol(submat[[i]][[1]])
    
    auxdet2 = NULL;
    for(j in 1:length(submat[[i]])){
      aux_sub = submat[[i]][[j]][1,]
      cofator = rep(-1,dim_mat)^(1+1:dim_mat) * auxdet1[((j-1)*dim_mat+1):(j*dim_mat)];
      auxdet2[j] = sum(aux_sub*cofator);
    }
    auxdet1 = auxdet2;
  }

Analisemos o código acima: 

  • O primeiro for (da variável i) irá caminhar pelos níveis da árvore, do penúltimo ao primeiro. 
  • Iniciamos a variável que armazenará os cofatores dos elementos da primeira linha da submatriz.
  • Verificamos a dimensão da submatriz desejada.
  • Iniciamos a variável auxdet2 para armazenar os determinantes do nível superior da matriz.
  • O segundo for (da variável j) irá caminhar pelas matrizes do nível i da árvore.
  • Para cada matriz desse nível, pegamos sua primeira linha, calculamos seus respectivos cofatores e, em seguida, calculamos o determinante dessa matriz e armazenamos em auxdet2.
  • Repetimos esse processo para todas as matrizes de cada nível da árvore, sempre subindo o nível (que, no caso, significa diminuir o índice i) até chegar à matriz original.
  • Quando i=1, calculamos o determinante da matriz desejada.

Após a execução acima, retornamos o resultado.

  return(auxdet1);

O código completo da função está abaixo:

calc_det_laplace = function(A){
  n = ncol(A);
  if(n==2){
    return(calc_det2(A));
  }else if(n==3){
    return(calc_det3(A));
  }
  
  submat = calc_all_submatriz(A);
  n_levels = length(submat);
  auxdet1 = NULL;
  
  for(i in 1:length(submat[[n_levels]])) auxdet1[i] = calc_det3(submat[[n_levels]][[i]])
    
  for(i in (n_levels-1):1){
    cofator = NULL;
    dim_mat = ncol(submat[[i]][[1]])
    
    auxdet2 = NULL;
    for(j in 1:length(submat[[i]])){
      aux_sub = submat[[i]][[j]][1,]
      cofator = rep(-1,dim_mat)^(1+1:dim_mat) * auxdet1[((j-1)*dim_mat+1):(j*dim_mat)];
      auxdet2[j] = sum(aux_sub*cofator);
    }
    auxdet1 = auxdet2;
  }
  
  return(auxdet1);
}

Note que realizamos todo o processo sem o uso da recursão. Porém, pagamos o preço de armazenar toda a árvore de submatrizes.

Vamos testar nosso código com o mesmo exemplo do post anterior:

D = matrix(c(1,2,3,4,2,1,3,4,2,1,9,7,4,5,1,4),ncol=4,byrow=T)
print(D)

     [,1] [,2] [,3] [,4]
[1,]    1    2    3    4
[2,]    2    1    3    4
[3,]    2    1    9    7
[4,]    4    5    1    4

det(D)
[1] 72

calc_det_laplace(D)
[1] 72

Agora temos uma função para calcular o determinante de uma matriz utilizando o teorema de Laplace e de forma iterativa! No próximo post, vamos comparar a eficiência das funções que criamos com a função embutida no R.

Espero que tenha gostado da aula.

Até a próxima aula!

segunda-feira, 21 de fevereiro de 2022

Calculando determinante de uma matriz pelo Teorema de Laplace - Parte I

O determinante de uma matriz é uma função que, dada a matriz, retorna um número real. Na estatística, a própria fórmula da distribução normal multivariada apresenta o determinante da matriz de covariâncias. 

O computador realiza a operação para nós, sem nos preocuparmos como está sendo calculado. Mas você já parou para pensar em, além de como é calculado, como é implementado o algoritmo? Vamos fazer isso nesse post, vamos implementar o cálculo de determinante pelo Teorema de Laplace que utiliza o método do cofator. Para isso, apresentarei duas formas de implementá-lo: o método recursivo e o método iterativo.

Se você já viu algo sobre recursividade em programação, sabe que ela pode ser um vilão para a memória do computador. Dessa forma, transformar o algoritmo recursivo em um algoritmo iterativo pode ser uma boa ideia. Nesse post faremos o algoritmo recursivo e, no próximo, o algoritmo iterativo.

A primeira coisa que vamos fazer é implementar a função que calcula o determinante de matrizes de ordem 2 e 3, que pode ser feito de forma simples e direta. É o que fazemos no seguinte código: 

#  Determinante de matrizes de ordem 2 e 3 ---------------

calc_det2 = function(A){
  res = A[1,1]*A[2,2] - A[1,2]*A[2,1]
  return(res)
}

calc_det3 = function(A){
  res = A[1,1]*A[2,2]*A[3,3] + A[1,2]*A[2,3]*A[3,1] + A[1,3]*A[2,1]*A[3,2]
  res = res - (A[1,3]*A[2,2]*A[3,1] + A[1,1]*A[2,3]*A[3,2] + A[1,2]*A[2,1]*A[3,3])
  return(res)
}

Para matrizes de ordem maior que 3 o cálculo não é direto. Utilizamos, por exemplo, o teorema de Laplace, que diz que o determinante de uma matriz será dado pela soma do produto dos elementos de uma linha ou coluna (a sua escolha) pelos seus respectivos cofatores. Obviamente, esse método serve para as matrizes de ordem 3 e 2, porém o cálculo é mais complicado e o método direto é preferível.

Para calcular o cofator de um elemento, precisamos do determinante da matriz reduzida. De fato, $C(a_{ij}) = (-1)^{(i+j)} \times |D_{ij}|$, em que $a_{ij}$ é o elemento da linha i e coluna j; e  $D_{ij}$ é a matriz obtida retirando-se a linha i e a coluna j (que estou chamando de matriz reduzida). Logo, necessitamos do determinante de uma outra matriz para calcular o determinante da matriz original. Por exemplo, se queremos calcular o determinante de uma matriz 4x4 e escolhemos a primeira linha para aplicar o método, precisamos calcular o determinante de quatro matrizes reduzidas 3x3 primeiro (uma para o cofator de cada elemento da linha escolhida).

O que isso está nos dizendo? Que se temos uma matriz n x n e queremos calcular seu determinante pelo método do cofator, precisamos calcular o determinante de todas as matrizes reduzidas de ordem 3 primeiro para depois calcular as de ordem maior, até chegar na matriz original.

Na verdade, poderíamos reduzir as matrizes até a ordem 1 no método, mas já sabemos calcular o determinante de matrizes de ordem 3 diretamente, então poupamos cálculo e também memória do computador.

Vamos começar nosso algoritmo. Declaramos nossa função:

calc_det = function(A){
 ...
}

Agora basta implementar. Pense comigo, se a matriz passada pelo parâmetro é de ordem 2 ou 3, já sabemos calcular, correto? Então, basta verificar a ordem da matriz e chamar uma das duas funções.

if(ncol(A)==3){
    return(calc_det3(A));
  }else if(ncol(A)==2){
    return(calc_det2(A));
}

Agora, se a ordem da matriz é maior que 3, aplicamos o método do cofator. Podemos escolher qualquer linha ou coluna para aplicar o método e normalmente utilizamos a que contiver o maior número de zeros para facilitar os cálculos. Não faremos essa verificação aqui. Para simplificar o problema, vamos escolher sempre a primeira linha da matriz (inclusive das matrizes reduzidas, caso necessário) para realizar o método.

Além disso, precisamos de uma variável para armazenar o resultado do determinante e da matriz atual (matriz reduzida). Vejamos:

  L1 = A[1,]
  det_ = 0
  auxM = A[-1,]

Até aqui estamos apenas preparando o terreno. Note que auxM é a matriz passada na função sem a primeira linha, como queríamos. Agora, temos que retirar as colunas, o que será feito de forma iterativa, isto é, para cada elemento da linha, retiramos sua respectiva coluna. Vejamos:

  for(i in 1:length(L1)){
    auxM2 = auxM[,-i]
    auxdet = calc_det(auxM2)
    cofat = (-1)^(1+i) * auxdet
    det_ = det_ + L1[i]*cofat
  }

Vamos por partes, das linhas internas ao for: 

  • Linha 1: calcula a matriz reduzida retirando a i-ésima coluna da matriz auxM.
  • Linha 2: calcula o determinante da matriz reduzida.
  • Linha 3: calcula o cofator associado ao elemento atual da linha escolhida da matriz.
  • Linha 4: soma o cálculo do cofator ao resultado final.
Agora é a parte interessante. Quando o código vai da linha 2 para a linha 3? Apenas quando calculamos o determinante da matriz reduzida. Se você reparou, estamos chamando a função calc_det dentro dela. Chamamos isso de recursão em programação. Antes de avançar para a linha 3, a linha 2 deve ser finalizada, mas isso acontecerá apenas quando a função retornar o determinante da matriz reduzida. 

Por exemplo, se estamos trabalhando com uma matriz 4x4, as matrizes reduzidas serão de ordem 3 e a função retornará o determinante dessas matrizes em uma etapa (pois calculamos diretamente). Mas se estamos trabalhando com uma matriz 5x5, as matrizes reduzidas serão de ordem 4 e, para calcular seu determinante, necessitamos das matrizes reduzidas de ordem 3. Logo, para avançar da linha 2 para a linha 3, vários códigos são executados recursivamente. Quando obtemos os determinantes das matrizes reduzidas de ordem 3, conseguimos calcular os determinantes das matrizes reduzidas de ordem 4 para, então, conseguir calcular o determinante da matriz original de ordem 5. Parece confuso, mas não é. Caso necessite, uma boa estudada sobre recursão ajuda.

Ao final da execução, devemos retornar o resultado:

  return(aux)

O código completo da função está abaixo:

calc_det = function(A){
  if(ncol(A)==3){
    return(calc_det3(A));
  }else if(ncol(A)==2){
    return(calc_det2(A));
  }
  
  L1 = A[1,]
  det_ = 0
  auxM = A[-1,]
  
  for(i in 1:length(L1)){
    auxM2 = auxM[,-i]
    auxdet = calc_det(auxM2)
    cofat = (-1)^(1+i) * auxdet
    det_ = det_ + L1[i]*cofat
  }
  return(det_)
}
Vamos testar nosso código. Primeiro com uma matriz de ordem 4:
D = matrix(c(1,2,3,4,2,1,3,4,2,1,9,7,4,5,1,4),ncol=4,byrow=T)
print(D)

     [,1] [,2] [,3] [,4]
[1,]    1    2    3    4
[2,]    2    1    3    4
[3,]    2    1    9    7
[4,]    4    5    1    4

det(D)
[1] 72

calc_det(D)
[1] 72

A função det é a função implementada no R e estamos utilizando para verificar se nossa função calc_det está retornando o resultado correto. Podemos ver que nossa função retornou o resultado esperado.

Vamos testar com uma matriz de ordem 5:

E = matrix(c(3,9,1,8,1,7,4,9,2,1,2,3,
             4,2,1,3,4,2,1,9,7,4,5,1,4),ncol=5,byrow=T)
print(E)

     [,1] [,2] [,3] [,4] [,5]
[1,]    3    9    1    8    1
[2,]    7    4    9    2    1
[3,]    2    3    4    2    1
[4,]    3    4    2    1    9
[5,]    7    4    5    1    4

det(E)
[1] -629

calc_det(E)
[1] -629

Agora, temos nossa própria função para calcular o determinante de uma matriz via método do cofator (ou teorema de Laplace)!

No próximo post, faremos a versão iterativa do nosso algoritmo e vamos comparar a eficiência das função.

Espero que tenha gostado da aula.

Até a próxima aula!

segunda-feira, 31 de janeiro de 2022

Automatizando código para salvar arquivos

Quando estamos realizando alguma tarefa, normalmente iterativa, às vezes queremos salvar nosso progresso atual antes de toda a tarefa terminar. Isso evita que interrupções no software façam com que nossos resultados sejam perdidos. Por exemplo, você está realizando um Gibbs sampling e não quer perder suas estimativas caso algo aconteça antes do algoritmo terminar. Sabemos que modelos complexos em Inferência Bayesiana necessitam de mais tempo para realizar inferência por amostragem.
Suponha que você colocou o algoritmo para rodar 100 mil iterações. Mas, a cada 10 mil, quer salvar seu progresso, para evitar perda de resultados. Como fazemos isso?

Primeiro, vamos aprender a salvar um arquivo. Aqui, vou ensinar a usar a função saveRDS(). Essa função salva um objeto do R em um arquivo com extensão .rds e nome especificado pelo usuário. Vejamos um exemplo:
dados = c(1,20,12,90,5);
saveRDS(dados,"teste01.rds");

dados recebe um vetor com os números acima. Em seguida, salvamos o que tem na variável dados no arquivo "teste01.rds". Dessa forma, no diretório atual (que no caso, é o diretório do meu projeto) o arquivo é criado. É importante não esquecer de colocar a extensão do arquivo.

Agora, vamos ler o arquivo e ver o que tem nele.

dadosr = readRDS("teste01.rds")

> dadosr
[1]  1 20 12 90  5

Como esperado, dadosr recebe o que tem no arquivo "teste01.rds", que é o vetor de dados que salvamos inicialmente. Mas, apenas vetores podem ser salvos dessa forma? A resposta é não! Qualquer objeto pode ser gravado dessa maneira. Vejamos um exemplo com lista:

dados_aluno = list(nome = "João", disciplina = "Matematica", nota = 8.7)
saveRDS(dados_aluno,"teste02.rds");

Pronto, a lista criada que armazena o nome, disciplina e nota agora está salva no arquivo "teste02.rds". É claro que há maneiras mais eficientes para armazenar esse tipo de dado, mas aqui é apenas uma ilustração. Se lemos esse arquivo, obtemos:

dados_aluno_r = readRDS("teste02.rds")

> dados_aluno_r
$nome
[1] "João"

$disciplina
[1] "Matematica"

$nota
[1] 8.7

Agora que você sabe salvar um arquivo, é trivial automatizar gravação de resultados. Normalmente o fazemos em algoritmos iterativos e adicionamos o comando de gravação dentro da estrutura de repetição. Obviamente, temos que ter um controle de quanto em quanto tempo queremos gravar os resultados, pois, se gravamos a cada iteração, o algoritmo pode ficar extremamente lento. 

No exemplo mencionado, podemos optar por salvar um arquivo dos resultados a cada 10 mil iterações, até que as 100 mil sejam finalizadas. Ou podemos optar por gravar a cada 1 mil iterações. Esse "tempo" depende do problema em mãos. Se é algo que demora bastante (modelos muito complexos), salvar a cada poucas iterações é interessante. Agora, se o algoritmo é relativamente rápido, podemos dar um salto maior para a gravação. É claro que, se o algoritmo é rápido (algo factível sem o risco de perda de resultados), não precisamos salvar resultados preliminares.

Nesse post, faremos um exemplo banal para ilustrar a importância da automatização de código para gravar arquivos. Vamos gerar duas variáveis aleatórias e o resultado será gravado a cada mil iterações de um total de 10 mil. Vejamos o código abaixo:

result = matrix(ncol=2);
result = result[-1,];

for(i in 1:10000){
  x1 = rnorm(1); # gera uma observação da normal padrão
  x2 = runif(1); # gera uma observação da Uniforme(0,1)
  
  result = rbind(result,cbind(x1,x2));
  
  if(i%%1000 == 0) saveRDS(result,paste0("save_files/result_temp",i,".rds"));
}
saveRDS(result,"result.rds");

Primeiro, criamos a variável result, que armazenará uma matriz. Como ela é iniciada com uma linha contendo NA's, excluímos essa linha. Em seguida, iniciamos uma iteração de tamanho 10 mil. Para cada iteração, geramos um valor de uma distribuição normal padrão e um valor de uma uniforme(0,1) e esse resultado é adicionado a uma linha na matriz. Agora a parte interessante:

if(i%%1000 == 0) saveRDS(result,paste0("save_files/result_temp",i,".rds"));

O que essa linha de código está fazendo? Verificamos se passaram-se mil iterações. Caso positivo, salvamos nosso resultado prévio. Note que estou salvando o arquivo na pasta "save_files", com um nome que depende do valor de i. Ou seja, a cada mil iterações, vamos salvar nosso resultado com o nome "result_temp1000.rds", "result_temp2000.rds" e assim por diante. Se executarmos o código acima, veremos no nosso diretório os seguintes arquivos:

Esses arquivos foram salvos para você visualizar que o processo está funcionando. Se modificarmos o código para salvar o arquivo com o mesmo nome, a cada gravação o arquivo será sobrescrito. Isso é útil, já que queremos sempre o arquivo mais atualizado. Então, modificamos o código para:

if(i%%1000 == 0) saveRDS(result,"save_files/result_temp.rds");

Agora, os resultados prévios serão sempre salvos no arquivo "result_temp.rds". Eu particularmente gosto de colocar a palavra "temp" no nome para indicar que é um arquivo temporário ou de resultados prévios. Após a execução de todo o algoritmo, salvamos o resultado no arquivo "result.rds", que contém o resultado completo.

Note que, após finalizar o for, o arquivo "result_temp.rds" também armazenará o resultado completo. Normalmente isso depende se a última iteração do algoritmo vai salvar ou não os resultados prévios e depende da estrutura do nosso algoritmo. Varia de problema para problema. De qualquer modo, analise o seu problema e veja se é necessário salvar um arquivo após a execução da estrutura de repetição. No nosso caso, não é necessário e seria mal uso de memória física e de tempo, principalmente se o arquivo for muito grande. Sempre pense nesses detalhes!

Espero que tenha gostado da aula.

Até a próxima aula!