Skip to content

neighbors.models.NNMF_mult

The non-negative matrix factorization algorithm tries to decompose a users x items matrix into two additional matrices: users x factors and factors x items.

Training is performed via multiplicative updating and continues until convergence or the maximum number of training iterations has been reached. Unlike the NNMF_sgd, this implementation takes no hyper-parameters and thus is simpler and faster to use, but less flexible, i.e. no regularization.

The number of factors, convergence, and maximum iterations can be controlled with the n_factors, tol, and max_iterations arguments to the .fit method. By default the number of factors = the number items.

The implementation here follows closely that of Lee & Seung, 2001 (eq 4): https://papers.nips.cc/paper/2000/file/f9d1152547c0bde01830b7e8bd60024c-Paper.pdf

Note: random_state does not control the sgd fit, only the initialization of the factor matrices

Source code in neighbors/models.py
class NNMF_mult(BaseNMF):
    """
    The non-negative matrix factorization algorithm tries to decompose a users x items matrix into two additional matrices: users x factors and factors x items.

    Training is performed via multiplicative updating and continues until convergence or the maximum number of training iterations has been reached. Unlike the `NNMF_sgd`, this implementation takes no hyper-parameters and thus is simpler and faster to use, but less flexible, i.e. no regularization.

    The number of factors, convergence, and maximum iterations can be controlled with the `n_factors`, `tol`, and `max_iterations` arguments to the `.fit` method. By default the number of factors = the number items.

    The implementation here follows closely that of Lee & Seung, 2001 (eq 4): https://papers.nips.cc/paper/2000/file/f9d1152547c0bde01830b7e8bd60024c-Paper.pdf

    *Note*: `random_state` does not control the sgd fit, only the initialization of the factor matrices
    """

    def __init__(
        self, data, mask=None, n_mask_items=None, verbose=True, random_state=None
    ):
        """
        Args:
            data (pd.DataFrame): users x items dataframe
            mask (pd.DataFrame, optional): A boolean dataframe used to split the data into 'observed' and 'missing' datasets. Defaults to None.
            n_mask_items (int/float, optional): number of items to mask out, while the rest are treated as observed; Defaults to None.
            data_range (int/float, optional): max - min of the data; Default computed from the input data. This is useful to set manually in case the input data do not span the full range of possible values
            random_state (None, int, RandomState): a seed or random state used for all internal random operations (e.g. randomly mask half the data given n_mask_item = .05). Passing None will generate a new random seed. Default None.
            verbose (bool; optional): print any initialization warnings; Default True

        """
        super().__init__(
            data, mask, n_mask_items, random_state=random_state, verbose=verbose
        )
        self.H = None  # factors x items
        self.W = None  # user x factors
        self.n_factors = None

    def __repr__(self):
        return f"{super().__repr__()[:-1]}, n_factors={self.n_factors})"

    def fit(
        self,
        n_factors=None,
        n_iterations=1000,
        tol=1e-6,
        eps=1e-6,
        verbose=False,
        dilate_by_nsamples=None,
        clip_predictions=True,
        **kwargs,
    ):
        """Fit NNMF collaborative filtering model to train data using multiplicative updating.

        Given non-negative matrix `V` find non-negative factors `W` and `H` by minimizing `||V - WH||^2`.

        Args:
            n_factors (int, optional): number of factors to learn. Defaults to None which includes all factors.
            n_iterations (int, optional): total number of training iterations if convergence is not achieved. Defaults to 5000.
            tol (float, optional): Convergence criteria. Model is considered converged if the change in error during training < tol. Defaults to 0.001.
            eps (float; optiona): small value added to denominator of update rules to avoid divide-by-zero errors; Default 1e-6.
            verbose (bool, optional): print information about training. Defaults to False.
            dilate_by_nsamples (int, optional): How many items to dilate by prior to training. Defaults to None.
            clip_predictions (bool, optional): clip predictions to the observed rating range, since factorization alone can produce predictions outside it. This is the same approach the [Surprise](https://surpriselib.com/) package takes when making predictions. Defaults to True.
        """

        # Call parent fit which acts as a guard for non-masked data
        super().fit()

        n_users, n_items = self.data.shape

        if (
            isinstance(n_factors, int) and (n_factors > n_items and n_factors > n_users)
        ) or isinstance(n_factors, np.floating):
            raise TypeError("n_factors must be an integer < number of items and users")

        if n_factors is None:
            n_factors = min([n_users, n_items])

        self.n_factors = n_factors
        self.clip_predictions = clip_predictions

        # Initialize W and H as non-negative scaled random values
        # We use random initialization scaled by the number of factors not unlike sklearn: https://github.com/scikit-learn/scikit-learn/blob/95119c13af77c76e150b753485c662b7c52a41a2/sklearn/decomposition/_nmf.py#L334
        self.W = np.abs(
            self.random_state.normal(scale=1.0 / n_factors, size=(n_users, n_factors))
        )
        self.H = np.abs(
            self.random_state.normal(scale=1.0 / n_factors, size=(n_factors, n_items))
        )

        # Whereas in SGD we explity pass in indices of training data for fitting, here we set testing indices to 0 so they have no impact on the multiplicative update. See Zhu, 2016 for more details: https://arxiv.org/pdf/1612.06037.pdf
        self.dilate_mask(n_samples=dilate_by_nsamples)

        # fillna(0) is equivalent to hadamard (element-wise) product with a binary mask
        X = self.masked_data.fillna(0).to_numpy()

        # Run multiplicative updating
        # Silence numba warning until this issue gets fixed: https://github.com/numba/numba/issues/4585
        with warnings.catch_warnings():
            warnings.filterwarnings("ignore", category=NumbaPerformanceWarning)
            error_history, converged, n_iter, delta, norm_rmse, W, H = mult(
                X,
                self.W,
                self.H,
                self.data_range,
                eps,
                tol,
                n_iterations,
                verbose,
            )

        # Save outputs to model
        self.W, self.H = W, H
        self.error_history = error_history
        self._n_iter = n_iter
        self._delta = delta
        self._norm_rmse = norm_rmse
        self.converged = converged

        if verbose:
            if self.converged:
                print("\n\tCONVERGED!")
                print(f"\n\tFinal Iteration: {self._n_iter}")
                print(f"\tFinal Delta: {np.round(self._delta)}")
            else:
                print("\tFAILED TO CONVERGE (n_iter reached)")
                print(f"\n\tFinal Iteration: {self._n_iter}")
                print(f"\tFinal delta exceeds tol: {tol} <= {self._delta}")

            print(f"\tFinal Norm Error: {np.round(100 * norm_rmse, 2)}%")
        self._predict()
        self.is_fit = True

    def _predict(self):
        """Predict subjects' missing items using NNMF with multiplicative updating"""

        predictions = self.W @ self.H
        if self.clip_predictions:
            predictions = np.clip(
                predictions,
                self.masked_data.min().min(),
                self.masked_data.max().max(),
            )
        self.predictions = pd.DataFrame(
            predictions, index=self.data.index, columns=self.data.columns
        )

__init__(self, data, mask=None, n_mask_items=None, verbose=True, random_state=None) special

Parameters:

Name Type Description Default
data pd.DataFrame

users x items dataframe

required
mask pd.DataFrame

A boolean dataframe used to split the data into 'observed' and 'missing' datasets. Defaults to None.

None
n_mask_items int/float

number of items to mask out, while the rest are treated as observed; Defaults to None.

None
data_range int/float

max - min of the data; Default computed from the input data. This is useful to set manually in case the input data do not span the full range of possible values

required
random_state None, int, RandomState

a seed or random state used for all internal random operations (e.g. randomly mask half the data given n_mask_item = .05). Passing None will generate a new random seed. Default None.

None
verbose bool; optional

print any initialization warnings; Default True

True
Source code in neighbors/models.py
def __init__(
    self, data, mask=None, n_mask_items=None, verbose=True, random_state=None
):
    """
    Args:
        data (pd.DataFrame): users x items dataframe
        mask (pd.DataFrame, optional): A boolean dataframe used to split the data into 'observed' and 'missing' datasets. Defaults to None.
        n_mask_items (int/float, optional): number of items to mask out, while the rest are treated as observed; Defaults to None.
        data_range (int/float, optional): max - min of the data; Default computed from the input data. This is useful to set manually in case the input data do not span the full range of possible values
        random_state (None, int, RandomState): a seed or random state used for all internal random operations (e.g. randomly mask half the data given n_mask_item = .05). Passing None will generate a new random seed. Default None.
        verbose (bool; optional): print any initialization warnings; Default True

    """
    super().__init__(
        data, mask, n_mask_items, random_state=random_state, verbose=verbose
    )
    self.H = None  # factors x items
    self.W = None  # user x factors
    self.n_factors = None

fit(self, n_factors=None, n_iterations=1000, tol=1e-06, eps=1e-06, verbose=False, dilate_by_nsamples=None, clip_predictions=True, **kwargs)

Fit NNMF collaborative filtering model to train data using multiplicative updating.

Given non-negative matrix V find non-negative factors W and H by minimizing ||V - WH||^2.

Parameters:

Name Type Description Default
n_factors int

number of factors to learn. Defaults to None which includes all factors.

None
n_iterations int

total number of training iterations if convergence is not achieved. Defaults to 5000.

1000
tol float

Convergence criteria. Model is considered converged if the change in error during training < tol. Defaults to 0.001.

1e-06
eps float; optiona

small value added to denominator of update rules to avoid divide-by-zero errors; Default 1e-6.

1e-06
verbose bool

print information about training. Defaults to False.

False
dilate_by_nsamples int

How many items to dilate by prior to training. Defaults to None.

None
clip_predictions bool

clip predictions to the observed rating range, since factorization alone can produce predictions outside it. This is the same approach the Surprise package takes when making predictions. Defaults to True.

True
Source code in neighbors/models.py
def fit(
    self,
    n_factors=None,
    n_iterations=1000,
    tol=1e-6,
    eps=1e-6,
    verbose=False,
    dilate_by_nsamples=None,
    clip_predictions=True,
    **kwargs,
):
    """Fit NNMF collaborative filtering model to train data using multiplicative updating.

    Given non-negative matrix `V` find non-negative factors `W` and `H` by minimizing `||V - WH||^2`.

    Args:
        n_factors (int, optional): number of factors to learn. Defaults to None which includes all factors.
        n_iterations (int, optional): total number of training iterations if convergence is not achieved. Defaults to 5000.
        tol (float, optional): Convergence criteria. Model is considered converged if the change in error during training < tol. Defaults to 0.001.
        eps (float; optiona): small value added to denominator of update rules to avoid divide-by-zero errors; Default 1e-6.
        verbose (bool, optional): print information about training. Defaults to False.
        dilate_by_nsamples (int, optional): How many items to dilate by prior to training. Defaults to None.
        clip_predictions (bool, optional): clip predictions to the observed rating range, since factorization alone can produce predictions outside it. This is the same approach the [Surprise](https://surpriselib.com/) package takes when making predictions. Defaults to True.
    """

    # Call parent fit which acts as a guard for non-masked data
    super().fit()

    n_users, n_items = self.data.shape

    if (
        isinstance(n_factors, int) and (n_factors > n_items and n_factors > n_users)
    ) or isinstance(n_factors, np.floating):
        raise TypeError("n_factors must be an integer < number of items and users")

    if n_factors is None:
        n_factors = min([n_users, n_items])

    self.n_factors = n_factors
    self.clip_predictions = clip_predictions

    # Initialize W and H as non-negative scaled random values
    # We use random initialization scaled by the number of factors not unlike sklearn: https://github.com/scikit-learn/scikit-learn/blob/95119c13af77c76e150b753485c662b7c52a41a2/sklearn/decomposition/_nmf.py#L334
    self.W = np.abs(
        self.random_state.normal(scale=1.0 / n_factors, size=(n_users, n_factors))
    )
    self.H = np.abs(
        self.random_state.normal(scale=1.0 / n_factors, size=(n_factors, n_items))
    )

    # Whereas in SGD we explity pass in indices of training data for fitting, here we set testing indices to 0 so they have no impact on the multiplicative update. See Zhu, 2016 for more details: https://arxiv.org/pdf/1612.06037.pdf
    self.dilate_mask(n_samples=dilate_by_nsamples)

    # fillna(0) is equivalent to hadamard (element-wise) product with a binary mask
    X = self.masked_data.fillna(0).to_numpy()

    # Run multiplicative updating
    # Silence numba warning until this issue gets fixed: https://github.com/numba/numba/issues/4585
    with warnings.catch_warnings():
        warnings.filterwarnings("ignore", category=NumbaPerformanceWarning)
        error_history, converged, n_iter, delta, norm_rmse, W, H = mult(
            X,
            self.W,
            self.H,
            self.data_range,
            eps,
            tol,
            n_iterations,
            verbose,
        )

    # Save outputs to model
    self.W, self.H = W, H
    self.error_history = error_history
    self._n_iter = n_iter
    self._delta = delta
    self._norm_rmse = norm_rmse
    self.converged = converged

    if verbose:
        if self.converged:
            print("\n\tCONVERGED!")
            print(f"\n\tFinal Iteration: {self._n_iter}")
            print(f"\tFinal Delta: {np.round(self._delta)}")
        else:
            print("\tFAILED TO CONVERGE (n_iter reached)")
            print(f"\n\tFinal Iteration: {self._n_iter}")
            print(f"\tFinal delta exceeds tol: {tol} <= {self._delta}")

        print(f"\tFinal Norm Error: {np.round(100 * norm_rmse, 2)}%")
    self._predict()
    self.is_fit = True