Fatoração de Matrizes

Compartilhar:
Fatoração de Matrizes

A Fatoração de matrizes (Matrix Factorization) é uma técnica matemática amplamente utilizada em sistemas de recomendação, especialmente no filtro colaborativo (Collaborative Filtering), para prever as preferências dos usuários com base em suas interações com itens. Essa abordagem é crucial para plataformas de streaming, e-commerce e redes sociais, pois permite recomendações personalizadas.

A ideia principal é "quebrar" uma matriz de interações \(R\), com dimensões \([m,n]\), em duas matrizes menores que representam características latentes de usuários e itens:

$$\huge R≈U*V$$

  1. \(m\) é o número de usuários.
  2. \(n\) é o número de itens.
  3. A matriz \(U (de \ dimensões \ [m,k])\) Representa os usuários em um espaço de características latentes.
  4. A matriz \(V (de \ dimensões \ [k,n])\) Representa os itens em um espaço de características latentes.

O valor \(k\) define o número de fatores latentes, que capturam padrões implícitos nas interações entre usuários e itens.

Exemplo

Imagine um cenário futurista, em uma festa iluminada por luzes de néon suaves e um som ambiente agradável ao fundo. Robbie, um robô curioso e apaixonado por música, se encontra no centro de uma roda de conversa bastante peculiar. Ao seu redor, quatro figuras icônicas discutem sobre seus gostos musicais: Ripley, Darth Vader, Spock e Hermione. Robbie ajusta seus sensores auditivos e se inclina ligeiramente para frente, ansioso para aprender sobre os gostos musicais de cada personagem.

Robbie cria uma matriz de interações com dimensões \([m, n]\) com os valores de classificação dada pelos personagens.

Vamos carregar os dados de exemplo das interações dos usuários com as músicas (daqui em diante vamos chamar os personagens de usuários).

obs: Todo código utilizado nesse artigo está disponível no github .


    import pandas as pd
    import numpy as np
    import examples as ex

    matrix_interactions = ex.get_matrix_interaction()
    matrix_interactions
            
            

Resultado da matrix_interactions

UsuárioAnother One Bites the DustBack in BlackBohemian RhapsodyBrandenburg Concerto No. 3Clair de LuneComfortably NumbDon't Stop Believin'FireworkHedwig’s ThemeO FortunaSurvivorThe Imperial March
Darth Vader442012200405
Hermione105052255221
Ripley552123440554
Spock205554323420

Cada célula contém uma nota de 0 a 5, onde 0 indica que o usuário nunca ouviu a música. O objetivo de Robbie é prever quais músicas poderiam ser recomendadas a cada usuário, baseado-se na semelhança de gostos entre eles. Para prever novas preferências, ele decide usar Fatoração de matrizes
(Matrix Factorization)
.

Fatoração de Matriz na Prática

A fatoração de matrizes é o processo de "quebrar" uma matriz original \(R\) em duas matrizes menores, \(U\) e \(V\), onde ambas possuem \(k\) dimensões latentes. Essa "quebra" transforma a matriz de interações entre usuários e itens (músicas no nosso contexto) em um espaço de características ocultas, permitindo capturar padrões ocultos nos dados e prever interações ausentes de forma eficiente.

Para iniciar a implementação, precisamos inicializar as matrizes \(U\) e \(V\) com valores aleatórios seguindo uma distribuição normal. Para evitar que esses valores iniciais sejam muito grandes, definimos o parâmetro scale como \(1.0/k\). Isso garante que os valores tenham um desvio padrão inversamente proporcional ao número de dimensões latentes, estabilizando o processo de otimização e melhorando a qualidade das previsões do modelo.


    ratings = matrix_interactions.values
    k = 5 # numer of latent dimensions

    num_users, num_items = ratings.shape

    # Initialize user and item latent feature matrices
    user_matrix = np.random.normal(scale=1. / k, size=(num_users, k))  # U matrix
    item_matrix = np.random.normal(scale=1. / k, size=(num_items, k))  # V matrix
            

Maximização do Erro e Métodos de Otimização

Com as matrizes \(U\) e \(𝑉\) inicializadas, o próximo passo é otimizar os fatores latentes ajustando as previsões às interações reais. O objetivo é minimizar o erro entre as pontuações conhecidas e as previsões do modelo nas lacunas da matriz original.

Para esse processo, dois algoritmos são amplamente utilizados em fatoração de matrizes:

  1. Stochastic Gradient Descent (SGD) – um método iterativo que ajusta os fatores latentes reduzindo gradativamente o erro através da descida do gradiente. Este será o foco deste artigo.
  2. Alternating Least Squares (ALS) – uma abordagem que resolve as matrizes \(U\) e \(V\) alternadamente usando mínimos quadrados. Esse método será explorado em outro artigo.

Nos próximos passos, veremos como implementar o SGD para otimizar os fatores latentes e melhorar a precisão das previsões.

Implementação do algoritmo SGD

O SGD realiza três etapas principais:

  1. Escolher um par \((i,j)\) onde \(R_{ij}\) não seja nulo.
  2. Calcular o erro da previsão para essa interação:
    • \(e_{ij} = R_{ij}−\hat R_{ij} = R_{ij}−U_iV_j^T\)
  3. Atualizar os valores das matrizes \(U\) e \(V\) usando a regra de atualização do gradiente descendente:
    • \(U_i \leftarrow U_i + \alpha * (2 * e_{ij} * V_j - \lambda U_i)\)
    • \(V_j \leftarrow V_j + \alpha * (2 * e_{ij} * U_i - \lambda V_j) \)
  • \(\alpha\) é a taxa de aprendizado, que controla o tamanho do ajuste em cada iteração.
  • \(\lambda\) é o termo de regularização, que evita overfitting penalizando valores muito altos nos fatores latentes

Esse processo se repete para diversas interações ao longo das épocas de treinamento, reduzindo progressivamente o erro.


    alpha = 0.01 # learning rate
    lambda_ = 0.02 # regularization term

    """
    - Returns the predicted rating of user i for item j.
    - It does this through the dot product between the user feature vector (users[i, :]) and the item feature vector (items[j, :]).
    - The resulting vector indicates the affinity between the user and the item.
    """
    def predict(users, items, i, j):
        return np.dot(users[i, :], items[j, :].T)

    """
    - This function performs training using Stochastic Gradient Descent (SGD).
    - ratings.nonzero() returns the indices (i, j) of the non-zero positions in the ratings array, i.e. where there is a known rating.
    - ratings[i, j]: actual value of the rating given by user i to item j.
    - predict(users, items, i, j): rating prediction calculated by the model.
    - eij: diferença entre o valor real e o previsto.
    - The term 2 * eij * items[j, :] corrects the direction of the user vector based on the prediction error.
    - The term - lambda_ * users[i, :] is the regularization to avoid overfitting.
    - After the updates, the function returns the adjusted users and items vectors, which contain the final user and item embeddings.
    """
    def sgd(ratings, users, items):
        for i, j in zip(*ratings.nonzero()):
            eij = ratings[i,j] - predict(users, items, i, j)
            users[i, :] += alpha * (2 * eij * items[j, :] - lambda_ * users[i, :])
            items[j, :] += alpha * (2 * eij * users[i, :] - lambda_ * items[j, :])

        return users, items
            

Agora que temos nossa função de otimização pronta. Vamos implementar a maximização do erro, que é feito pela soma do erro quadrático média (MSE).

$$\large \min_{U,V} \sum_{(i,j) \ \in \ \Omega } (R_{ij} - (U * V)_{ij})^2 + \lambda ( \| U \|^2 + \| V \|^2) $$

MSE (Mean Squared Error)

Vamos iniciar nossa implementação da função de otimização pelo cálculo do MSE.

$$\large \min_{U,V} \sum_{(i,j) \ \in \ \Omega } (R_{ij} - (U * V)_{ij})^2 = MSE = \dfrac{1}{n} \sum_{i=0}^{n} (y_{i} - \hat y_{i})²$$

  1. \( (i,j) \in \Omega\) é o conjunto de índices onde \(R_{ij}\) é conhecido e pertencem ao conjunto
    \(\Omega = \{\ (i,j) \ | \ R_{ij} \ é \ conhecido \ \}\). É o número total de interações conhecidas.
  2. \((U * V)_{ij}\) são os valores estimados de \(R_{ij}\) pelo modelo.
  3. \(R_{ij}\) matriz original das classificações.

O MSE é crucial no SGD por vários motivos:

  1. Fornece uma medida clara do erro
    • Ele quantifica a diferença média ao quadrado entre os valores reais e os valores estimados, permitindo avaliar a qualidade das previsões do modelo.
  2. É suave e diferenciável
    • O MSE tem derivadas contínuas, o que facilita sua otimização por métodos baseados em gradiente, como o SGD.
  3. Penaliza grandes erros
    • Como o erro é elevado ao quadrado, erros maiores têm um impacto desproporcional maior no valor final do MSE. Isso incentiva o modelo a reduzir grandes discrepâncias, melhorando a precisão das previsões.
  4. Facilita a interpretação do desempenho do modelo
    • Valores altos de MSE indicam que o modelo ainda precisa ser otimizado, enquanto valores baixos sugerem que ele está se ajustando bem aos dados.

    """
    - This variable will accumulate the squared error of each pair (i, j) in the ratings matrix.
    - ratings.nonzero() returns the indices (i, j) where the ratings are known (nonzero).
    - ratings[i, j]: actual rating given by user i to item j.
    - (ratings[i, j] - y_pred) ** 2: squared error between actual and predicted value.
    """
    def compute_loss(ratings, users, items):
        error = 0
        for i, j in zip(*ratings.nonzero()):
            y_pred = predict(users, items, i, j)
            error += (ratings[i, j] - y_pred) ** 2

        mse = np.mean(error)

        return mse
            

Vamos implementar agora a segunda parte da equação de otimização.

$$\huge \lambda ( \| U \|^2 + \| V \|^2) $$

  1. \(\lambda\) é a Taxa de regularização.
  2. \(( \| U \|^2 + \| V \|^2)\) são usados como regularização para evitar overfitting, limitando o tamanho dos parâmetros e garantindo que eles não cresçam excessivamente.

    """
    - This variable will accumulate the squared error of each pair (i, j) in the ratings matrix.
    - ratings.nonzero() returns the indices (i, j) where the ratings are known (nonzero).
    - ratings[i, j]: actual rating given by user i to item j.
    - (ratings[i, j] - y_pred) ** 2: squared error between actual and predicted value.
    """
    def compute_loss(ratings, users, items):
        error = 0
        for i, j in zip(*ratings.nonzero()):
            y_pred = predict(users, items, i, j)
            error += (ratings[i, j] - y_pred) ** 2

        mse = np.mean(error)

        # Regularization term to avoid overfitting
        # Penalizes large magnitudes in user and item vectors
        regularization = lambda_ * (np.linalg.norm(users)**2 + np.linalg.norm(items)**2)

        return mse + regularization
            

Por que usar regularização?

  1. Evita overfitting
    • Valores altos nas matrizes user_matrix e item_matrix podem levar a um modelo que se ajusta perfeitamente aos dados de treinamento, mas tem desempenho ruim em dados não vistos.
    • A regularização mantém os recursos aprendidos compactos e evita magnitudes excessivas de parâmetros.
  2. Melhora a estabilidade numérica
    • Sem regularização, as matrizes de fatores podem crescer demais, causando instabilidade e erros no sistema.
    • Adicionar o termo de penalidade controla a escala de user_matrix e item_matrix, estabilizando o treinamento.
  3. Incentiva a generalização
    • Ele garante que fatores latentes capturem padrões significativos em vez de ruído.

Pronto. Agora que temos a função de otimização para fatoração de matrizes e maximização do erro, podemos iniciar o treinamento.


    epochs = 1000
    for epoch in range(epochs):
        user_matrix, item_matrix = sgd(ratings, user_matrix, item_matrix)

        if epoch % 100 == 0:
            error = compute_loss(ratings, user_matrix, item_matrix)
            print(f'Epoch {epoch + 1}/{epochs} - Error: {error:.4f}')

    """
    Train output:
    Epoch 1/1000 - Error: 596.5902
    Epoch 101/1000 - Error: 1.6179
    Epoch 201/1000 - Error: 1.6440
    Epoch 301/1000 - Error: 1.6707
    Epoch 401/1000 - Error: 1.6952
    Epoch 501/1000 - Error: 1.7164
    Epoch 601/1000 - Error: 1.7343
    Epoch 701/1000 - Error: 1.7491
    Epoch 801/1000 - Error: 1.7612
    Epoch 901/1000 - Error: 1.7710
    """
            

No início do treinamento, geralmente iniciamos as matrizes de forma aleatória. Então, o modelo está praticamente “chutando” valores nas previsões. Por isso o erro começa lá em cima (no exemplo, por volta de 596)

Conforme o algoritmo SGD ajusta os parâmetros, ele vai “descobrindo” aos poucos a melhor forma de alinhar esses vetores de usuário e de item para se aproximar das interações reais. Isso explica por que, após algumas iterações, o erro já cai para perto de 1,6. O modelo rapidamente pega os padrões mais “óbvios” dos dados.

Depois dessa fase inicial de grande queda, o erro tende a estabilizar e até oscilar um pouco. É como se o modelo tivesse aprendido a maior parte das preferências gerais dos usuários, mas ainda precisa refinar detalhes mais sutis (ou lidar com dados que não seguem muito a tendência geral). À medida que o número de épocas cresce (200, 300, 400...), o modelo vai fazendo pequenos ajustes finos, o que faz o erro não cair tão drasticamente, mas manter-se em torno de 1,6 a 1,7.

Por que o erro não chega a zero? Em aplicações reais, sempre existe algum nível de ruído: usuários podem dar notas de forma inconsistente, certos itens podem ter poucas avaliações, e assim por diante. Além disso, aplicamos “regularização” para evitar que o modelo simplesmente decore cada pontinho da matriz (o chamado overfitting). Essa regularização “segura” um pouco o ajuste e impede que o modelo se adapte demais a ruídos ou dados incompletos.

Recomendando novas músicas

Agora que definimos todas as partes do processo, vamos juntar o código.


    import numpy as np

    class MatrixFactorization:
        def __init__(self, n_factors, learning_rate, regularization):
            """
            Matrix Factorization using Stochastic Gradient Descent (SGD) to factorize a matrix R into two matrices U and V.
            :param n_factors: Number of latent factors
            :param learning_rate: Learning rate
            :param regularization: Regularization parameter
            """
            self._k = n_factors
            self._alpha = learning_rate
            self._lambda = regularization

            self._original_matrix = None

            self._user_matrix = None
            self._item_matrix = None

            self._num_users = None
            self._num_items = None


        def fit(self, matrix, epochs=100) -> np.ndarray:
            """
            Fit the matrix factorization model to the data
            :param matrix: the matrix to factorize
            :param epochs: the number of epochs to train the model
            :return: the predicted ratings
            """
            self._initialize(matrix)

            for i in range(epochs):
                self._sgd()
                loss = self._compute_loss()
                print(f'Epoch {i + 1}/{epochs} - Training Loss: {loss:.4f}')

            return self.predict()

        def predict(self) -> np.ndarray:
            """
            Predict the ratings for every user and item.
            U matrix * V matrix^T
            :return: the predicted ratings
            """
            return self._user_matrix.dot(self._item_matrix.T)

        def _predict(self, i, j) -> np.ndarray:
            """
            Predict the rating of user i for item j
            U[i, :] * V[j, :].T
            :param i: Index of the user
            :param j: Index of the item
            :return: the predicted rating
            """
            return self._user_matrix[i, :].dot(self._item_matrix[j, :].T)

        def _sgd(self):
            """
            Perform Stochastic Gradient Descent to learn the latent factors U and V by minimizing the loss function using the original matrix R.
            :return: None
            """
            for i, j in zip(*self._original_matrix.nonzero()):
                eij = self._original_matrix[i, j] - self._predict(i, j)

                self._user_matrix[i] += self._alpha * (2 * eij * self._item_matrix[j] - self._lambda * self._user_matrix[i])
                self._item_matrix[j] += self._alpha * (2 * eij * self._user_matrix[i] - self._lambda * self._item_matrix[j])

        def _compute_loss(self) -> float:
            """
            Compute the loss (MSE) of the prediction
            :return: the loss of the prediction
            """
            error = 0
            for i, j in zip(*self._original_matrix.nonzero()):
                error = self._original_matrix[i, j] - self._predict(i, j)
                error += error ** 2

            mse = np.mean(error)
            regularization = self._lambda * (np.linalg.norm(self._user_matrix) + np.linalg.norm(self._item_matrix))
            return mse + regularization


        def _initialize(self, matrix):
            self._original_matrix = matrix.copy()
            self._num_users, self._num_items = self._original_matrix.shape

            self._user_matrix = np.random.normal(scale=1. / self._k, size=(self._num_users, self._k))  # U matrix
            self._item_matrix = np.random.normal(scale=1. / self._k, size=(self._num_items, self._k))  # V matrix
        

Treinamento

Agora que juntamos tudo, podemos fazer o treinamento do modelo. Mas antes, é importante normalizar os valores de ratings para manter o intervalo \([0, 5]\). Isso minimiza o impacto de valores extremos e permite ao modelo aprender padrões e relações de maneira mais eficiente e consistente.


    from sklearn.preprocessing import MinMaxScaler

    ratings = matrix_interactions.values

    scaler = MinMaxScaler(feature_range=(0, 5))
    normalized_ratings = scaler.fit_transform(ratings)

    model = MatrixFactorization(n_factors=75, learning_rate=0.001, regularization=0.02)
    matrix_factorization_result = model.fit(ratings, epochs=600)
        

Resultado da matrix_factorization_result

UserAnother One Bites the DustBack in BlackBohemian RhapsodyBrandenburg Concerto No. 3Clair de LuneComfortably NumbDon't Stop Believin'FireworkHedwig’s ThemeO FortunaSurvivorThe Imperial March
Darth Vader4.1699704.0572463.6674710.7106251.7125822.7727703.5957443.0188722.3913324.1902564.0129474.421050
Hermione2.6862203.3100095.0809724.5199925.0510423.9521283.2918654.7189394.8627344.3510252.1636110.979503
Ripley4.8383674.9184834.1064931.0435552.0237793.0121543.9922574.0594023.2006974.8234994.9271104.451684
Spock1.9941542.5236874.8944934.9740914.9044043.9899442.9982942.2538963.1323013.9943601.8507550.795050

Comparação da nota real vs previsão do model:

UsuárioMúsicaNota RealPrevisãoAnálise
Darth VaderBack in Black44.057246O modelo previu um valor muito próximo da nota real.
HermioneBohemian Rhapsody55.080972A predição é praticamente idêntica à nota dada por Hermione.
RipleyDon’t Stop Believin’43.992257O modelo previu corretamente uma interação forte.
SpockBohemian Rhapsody54.894493O modelo previu um valor muito próximo da interação real.
Darth VadeThe Imperial March54.421050O modelo quase acertou a nota, mas foi um pouco conservador.

Previsões das lacunas

Durante o treinamento do modelo com Matrix Factorization, algumas células na matriz de interações tinham valores iguais a 0. Isso significa que esses usuários não avaliaram essas músicas ou não tiveram interações registradas com elas. Então, como o modelo conseguiu prever essas notas ausentes?

O modelo preenche essas lacunas usando a similaridade entre os fatores latentes dos usuários e das músicas. Por exemplo, mesmo que Darth Vader nunca tenha escutado "Brandenburg Concerto No. 3" (nota 0 na matriz de interações), o modelo conseguiu prever uma nota 0,71. Isso foi possível porque a matriz fatorada aprendeu que Darth Vader tende a preferir músicas épicas e de ficção científica, mas não tem grande afinidade com música clássica.

Vamos ver outros exemplos de predições feitas pelo modelo:

De forma geral, o modelo faz essas predições multiplicando os fatores latentes dos usuários e itens, ajustados pelo Stochastic Gradient Descent (SGD). A multiplicação entre as matrizes \(U\) e \(V\) captura a relação entre as preferências gerais de cada usuário e as características das músicas.

À medida que o SGD minimiza o erro nas predições conhecidas, ele ajusta os fatores latentes para refletir corretamente os gostos individuais. Isso permite ao modelo generalizar e prever adequadamente as lacunas na matriz original, fornecendo sugestões de músicas personalizadas para cada personagem.

Isso permite que Robbie sugira novas músicas para cada usuário, personalizando suas recomendações.

Similaridade entre os usuários


    from sklearn.metrics.pairwise import cosine_similarity
    user_similarity = cosine_similarity(matrix_factorization_result)
    df_user_similarity = pd.DataFrame(user_similarity, index=ex.users, columns=ex.users)
    df_user_similarity
        
RipleyDarth VaderSpockHermione
Ripley1.00000.8366990.9967730.795352
Darth Vader0.8366991.00000.8564970.976032
Spock0.9967730.8564971.00000.805946
Hermione0.7953520.9760320.8059461.0000
  1. Ripley e Spock (0.996773):
    • Alta similaridade, pois ambos têm preferências fortes por músicas como “Bohemian Rhapsody” e “Back in Black”.
  2. Darth Vader e Hermione (0.976032):
    • Alta similaridade, o que faz sentido, já que ambos gostam de músicas épicas e clássicas, como “The Imperial March” e “Hedwig’s Theme”.
  3. Darth Vader e Spock (0.856497):
    • Similaridade moderada, pois embora tenham algumas preferências em comum, Spock tem um foco maior em músicas clássicas.

Recomendar novas músicas para Hermione

Após treinar o modelo, Robbie está pronto para fazer recomendações! Ele olhou para as músicas que Hermione ainda não escutou e analisou os gostos de outros personagens semelhantes a ela.


    def recommend_by_user(user, df_predicted, df_interactions, top_k=3):
        # Calculate user similarity matrix
        user_similarity = cosine_similarity(df_predicted.values)
        user_similarity_df = pd.DataFrame(user_similarity, index=ex.users, columns=ex.users)

        # Get the top similar users
        similarities = user_similarity_df.loc[user].sort_values(ascending=False)[1:top_k+1]
        print('Sser similarity score:')
        print(similarities)

        # Initialize recommendations
        recommendations = pd.Series(0, index=df_predicted.columns, dtype=np.float64)

        # Aggregate recommendations based on similar users
        for user_similar, similarity in similarities.items():
            recommendations = recommendations.add(
                df_predicted.loc[user_similar].reindex(recommendations.index, fill_value=0) * similarity,
                fill_value=0
            )

        # Filter out already known items
        already_known = df_interactions.loc[user] > 0
        recommendations = recommendations[~already_known.reindex(recommendations.index, fill_value=False)]

        return recommendations.sort_values(ascending=False)


    recommend_by_user('Hermione', df_matrix_interactions, matrix_interactions)

    """
    Output:
    Sser similarity score:
    Darth Vader    0.976032
    Spock          0.805946
    Ripley         0.795352
    """
        

Recomendações:

MúsicasScore
Back in Black9.905884
Brandenburg Concerto No. 35.532434

Robbie percebeu que os personagens mais semelhantes a Hermione, baseando-se nos gostos musicais, são:

Darth Vader tem os gostos mais parecidos com os de Hermione, então suas preferências tiveram mais peso nas recomendações.

Por que "Black in Black" foi recomendada?

Por que "Brandenburg Concerto No. 3" foi recomendado?

O modelo funciona como um amigo (Robbie) que conhece bem os seus gostos e os compara com os de outras pessoas. Ele percebeu que Hermione:

Robbie agora sabe exatamente como surpreender Hermione com novas músicas! 🎶

Conclusão

A Fatoração de Matrizes se destaca como uma ferramenta essencial para criar recomendações personalizadas em larga escala, ajudando plataformas como serviços de streaming e e-commerce a entenderem melhor seus usuários. Ao decompor a matriz de interações, conseguimos identificar padrões ocultos de preferências, mesmo quando há dados ausentes.

Com o Stochastic Gradient Descent (SGD), ajustamos as matrizes de forma iterativa, reduzindo o erro entre previsões e dados reais. A regularização garantiu um modelo equilibrado, sem overfitting, enquanto a normalização dos dados trouxe mais estabilidade ao aprendizado.

No final, vimos como o modelo vai além das interações diretas, prevendo preferências mesmo quando os dados são limitados. A história de Robbie e seus amigos icônicos nos mostrou que essas técnicas podem ser aplicadas a diversos cenários, proporcionando recomendações únicas e personalizadas. 🎶

Referências

Compartilhar: