Factorization Machines in Go

Introduction

Welcome to this lesson on factorization machines, an important model in the realm of recommendation systems. Factorization machines (FM) excel at capturing interactions between variables, making them a powerful tool for both regression and classification tasks. For instance, they can predict a rating (regression) or calculate the likelihood of a recommendation (classification).

Review of Dataset Preparation

Before we delve into the implementation of a factorization machine, let's briefly revisit the dataset preparation process from the previous lesson. In the last lesson, you learned how to load JSON files and create a user-item interaction matrix using dummy variables in Go. You also enriched the dataset with additional features, such as user preferences and genre similarity. These steps resulted in a data matrix where each row represents a user-item interaction, and columns represent features such as user and item dummy variables, user features, item features, and the rating. This structured data is essential for training a factorization machine.

Theory Behind

Latent Vectors

Implementing the Factorization Machine Model: Part 1

Let's move on to the implementation of the factorization machine model in Go. We'll break this into parts to ensure clarity.

package main

import (
    "math"
    "math/rand"
)

// SimpleFactorizationMachine represents the FM model.
type SimpleFactorizationMachine struct {
    NFactors     int         // Number of latent factors
    NFeatures    int         // Number of features
    LearningRate float64     // Learning rate
    Epochs       int         // Number of training epochs
    Reg          float64     // Regularization parameter

    W0 float64                // Global bias
    W  []float64              // Linear coefficients (size: NFeatures)
    V  [][]float64            // Latent factors (size: NFeatures x NFactors)
}

// NewSimpleFactorizationMachine initializes a new FM model.
// Pass a *rand.Rand with a fixed seed for reproducibility.
func NewSimpleFactorizationMachine(nFactors, nFeatures int, learningRate float64, epochs int, reg float64, rng *rand.Rand) *SimpleFactorizationMachine {
    V := make([][]float64, nFeatures)
    for i := range V {
        V[i] = make([]float64, nFactors)
        for f := range V[i] {
            V[i][f] = rng.NormFloat64() * 0.1 // Small random values
        }
    }
    return &SimpleFactorizationMachine{
        NFactors:     nFactors,
        NFeatures:    nFeatures,
        LearningRate: learningRate,
        Epochs:       epochs,
        Reg:          reg,
        W0:           0,
        W:            make([]float64, nFeatures),
        V:            V,
    }
}

In this code, we define a struct to hold all model parameters. The NewSimpleFactorizationMachine function initializes the model, including the latent factor matrix V with small random values. Slices are used to represent arrays and matrices.

Centralizing Prediction Logic with a Helper Method

To keep our code concise, consistent, and free from duplication, we introduce a helper method called predictRow. This method is responsible for computing the prediction for a single input vector, encapsulating both the linear and interaction terms as defined by the factorization machine (FM) formula. By centralizing this logic, we ensure that both training and prediction use exactly the same computation, which reduces the risk of errors and makes the code easier to maintain.

// predictRow computes the FM prediction for a single input vector.
func (fm *SimpleFactorizationMachine) predictRow(xi []float64) float64 {
    n := fm.NFeatures
    linearTerms := fm.W0
    for j := 0; j < n; j++ {
        linearTerms += fm.W[j] * xi[j]
    }
    interactionTerm := 0.0
    for f := 0; f < fm.NFactors; f++ {
        sumVx, sumVx2 := 0.0, 0.0
        for j := 0; j < n; j++ {
            vjf := fm.V[j][f]
            xij := xi[j]
            sumVx += vjf * xij
            sumVx2 += vjf * vjf * xij * xij
        }
        interactionTerm += (sumVx*sumVx - sumVx2) / 2.0
    }
    return linearTerms + interactionTerm
}

Let's break down how this method works:

  • Linear Terms:
    The first part of the prediction is the linear component. This is calculated as the sum of the global bias (fm.W0) and the dot product of the feature coefficients (fm.W) with the input vector (xi). This captures the individual contribution of each feature to the prediction, similar to a standard linear regression model.

  • Interaction Terms:
    The second part of the prediction captures the pairwise interactions between features using latent factors. For each latent factor (dimension), the method computes two quantities:

    • sumVx: The sum of the products of each feature value and its corresponding latent factor for the current dimension.
    • sumVx2: The sum of the squared products for each feature and its latent factor. The interaction term for each latent factor is then calculated as (sumVx*sumVx - sumVx2) / 2.0, which efficiently computes the sum of all pairwise interactions for that factor. The total interaction term is the sum over all latent factors.
  • Centralization and Reusability:
    By encapsulating the prediction logic in this helper method, we ensure that both the training process (when updating parameters) and the prediction process (when making predictions on new data) use the exact same computation. This follows the DRY (Don't Repeat Yourself) principle, making the codebase easier to maintain and less prone to bugs.

This approach not only streamlines the code but also guarantees consistency and correctness throughout the model's lifecycle.

Gradient Descent

Regularization in Gradient Descent

Implementing the Factorization Machine Model: Part 2

Next, let's implement the training logic for our factorization machine using Go slices and explicit loops. The Fit method will update the model parameters using gradient descent. Notice how we use the predictRow helper to compute predictions, which keeps the method concise and consistent.

// Fit trains the factorization machine on the given data.
func (fm *SimpleFactorizationMachine) Fit(X [][]float64, y []float64) {
    m, n := len(X), fm.NFeatures
    for epoch := 0; epoch < fm.Epochs; epoch++ {
        for i := 0; i < m; i++ {
            xi := X[i]
            pred := fm.predictRow(xi)
            err := pred - y[i]

            // Update global bias (w0) - no regularization
            fm.W0 -= fm.LearningRate * err

            // Update linear coefficients (w) with regularization
            for j := 0; j < n; j++ {
                gradW := err*xi[j] + fm.Reg*fm.W[j]
                fm.W[j] -= fm.LearningRate * gradW
            }

            // Update latent factors (V) with regularization
            for f := 0; f < fm.NFactors; f++ {
                for j := 0; j < n; j++ {
                    vjf, xij := fm.V[j][f], xi[j]
                    sumVx := 0.0
                    for jp := 0; jp < n; jp++ {
                        if jp != j {
                            sumVx += fm.V[jp][f] * xi[jp]
                        }
                    }
                    gradV := err*xij*sumVx + fm.Reg*vjf
                    fm.V[j][f] -= fm.LearningRate * gradV
                }
            }
        }
    }
}
  • Prediction:
    For each training instance, we use the predictRow helper method to compute the current prediction based on the model's parameters. This ensures that the prediction logic is consistent and centralized.

  • Error Calculation:
    The error (err) is calculated as the difference between the predicted value and the actual target value for the current instance.

  • Global Bias Update (w0):
    The global bias term is updated by subtracting the product of the learning rate and the error. This term helps the model adjust for the overall average of the target variable.

  • Linear Coefficient Updates (w):
    For each feature, we compute the gradient of the loss with respect to the linear coefficient. The gradient consists of two parts:

    • The error term scaled by the feature value (err * xi[j])
    • The regularization term (fm.Reg * fm.W[j]), which discourages large weights and helps prevent overfitting. The coefficient is then updated by moving in the direction opposite to the gradient.
  • Latent Factor Updates (V):
    For each latent factor and each feature, we update the corresponding entry in the latent factor matrix. The gradient for each latent factor is:

    • The error term, scaled by the feature value and the sum of the products of the other features' latent factors and their values (err * xij * sumVx)
    • The regularization term (fm.Reg * vjf) The update ensures that the model learns how each feature interacts with every other feature through the latent factors.
  • Efficiency and Maintainability:
    By centralizing the prediction logic in the predictRow helper, we avoid code duplication and reduce the risk of inconsistencies. This makes the code easier to maintain and less error-prone.

  • Regularization:
    Regularization terms (fm.Reg * ...) are included in both the linear and latent factor updates to help prevent overfitting, especially when the number of features or latent factors is large.

This method iteratively updates all model parameters over multiple epochs, gradually reducing the prediction error on the training data. The explicit use of slices and loops makes the implementation clear and idiomatic in Go.

Implementing the Factorization Machine Model: Part 3

Now, let's implement the prediction logic as a method on the struct. This method will use the trained parameters to make predictions for new data. Again, we use the predictRow helper for each input row.

// Predict returns predictions for the given data.
func (fm *SimpleFactorizationMachine) Predict(X [][]float64) []float64 {
    m := len(X)
    yPred := make([]float64, m)
    for i := 0; i < m; i++ {
        yPred[i] = fm.predictRow(X[i])
    }
    return yPred
}

The code defines a Predict method for the SimpleFactorizationMachine struct, which generates predictions for a batch of input data. Here's what the code does, step by step:

  • It takes a 2D slice X as input, where each row represents a feature vector for a user-item interaction or data point.
  • It creates a slice yPred to store the predicted values, with the same length as the number of input rows.
  • It loops over each row in X, and for each row, it calls the predictRow helper method to compute the prediction using the model's current parameters.
  • The predicted value for each row is stored in the corresponding position in yPred.
  • After processing all input rows, it returns the slice yPred containing the predictions for the entire dataset.

This method allows you to efficiently generate predictions for multiple data points at once, using the trained factorization machine model.

Feature Standardization

Making Predictions and Evaluating Model Performance

Conclusion and Summary

In this lesson, we successfully implemented and evaluated a factorization machine model for recommendation systems in Go. We covered parameter initialization, feature standardization, training with gradient descent, making predictions, and evaluating model performance. This concludes our exploration of factorization machines and marks the end of this course module.

Congratulations on completing the course! The skills you've acquired here form a strong foundation for building and understanding recommendation systems. Continue exploring other models and refine your expertise in this dynamic field. Well done!

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal