Recommender

The Recommender package is a collaborative filtering engine for Canvas that turns member ratings into personalised product recommendations. It implements two complementary algorithms — item-based co-occurrence and Slope One weighted prediction — on top of the CakePHP 5 database layer. Rate products, query recommendations, and predict ratings with no machine-learning infrastructure required.

explanation

Core Concepts

The engine is built around two database tables and a small set of service classes. Understanding how they relate makes it straightforward to integrate recommendations into any Canvas application.

  • Ratings — Stored in vogoo_ratings. A rating is a float in [0.0, 1.0] assigned by a member to a product within a category. The special value -1.0 marks explicit disinterest.
  • Links — Stored in vogoo_links. Pre-computed item pairs with a co-occurrence count (cnt) and a Slope One differential (diff_slope). These must be populated before recommendations can be served.
  • Categories — All operations are scoped to a category. A default category is set in config and used when none is supplied explicitly. This allows a single table pair to serve multiple product catalogues.
  • RecommendationConfig — A value object holding all tunable constants. Injected via the Canvas DI container; no global configuration needed.
  • RecommendationEngine — Handles rating CRUD. Optionally maintains vogoo_links incrementally on every write.
  • ItemRecommender — Item-based CF and Slope One recommendations for both logged-in members and anonymous visitors.
  • UserSimilarity — Member similarity scoring and neighbour-based recommendations.
  • VisitorContext — In-memory rating store for anonymous visitors, serialisable to session.
  • Statistics — Catalogue-level counts and rankings.

Installation

Install via Composer:

composer require quellabs/recommender

Then publish the configuration file:

sculpt recommender:init

Edit config/recommender.php to tune the engine constants, then create the tables:

sculpt recommender:init-db

If you have existing ratings data, populate vogoo_links with a full rebuild:

sculpt recommender:rebuild-links

Quick Start

With Canvas (autowired)

RecommendationEngine and ItemRecommender are resolved automatically by the Canvas DI container — their constructors depend only on Connection (provided by quellabs/canvas-database) and RecommendationConfig (provided by this package), so no manual wiring is required:

use Quellabs\Recommender\RecommendationEngine;
use Quellabs\Recommender\ItemRecommender;

class ShopController {

    public function __construct(
        private RecommendationEngine $engine,
        private ItemRecommender $recommender,
    ) {}

    public function product(int $memberId, int $productId): Response {
        // Record a view
        $this->engine->automaticRating($memberId, $productId, purchase: false);

        // Fetch recommendations
        $recommended = $this->recommender->memberGetRecommendedItems($memberId, limit: 8);

        return $this->render('product', compact('recommended'));
    }
}

Standalone

use Cake\Database\Connection;
use Cake\Database\Driver\Mysql;
use Quellabs\Recommender\Config\RecommendationConfig;
use Quellabs\Recommender\RecommendationEngine;
use Quellabs\Recommender\ItemRecommender;

$connection = new Connection([
    'driver'   => Mysql::class,
    'host'     => 'localhost',
    'username' => 'root',
    'password' => '',
    'database' => 'mydb',
]);

$config      = new RecommendationConfig();
$engine      = new RecommendationEngine($connection, $config);
$recommender = new ItemRecommender($connection, $config);

Configuration

All options with their defaults:

// config/recommender.php
return [
    // Default category used when no category is passed to engine methods
    'category' => 1,

    // Minimum number of common ratings before similarity is considered reliable
    'threshold_nr_common_ratings' => 30,

    // Multiplier used in the similarity confidence calculation
    'threshold_mult' => 2,

    // Minimum rating for an item to count as "liked" in link calculations
    'threshold_rating' => 0.66,

    // Cost factor used in the member similarity spread calculation
    'cost' => 5.0,

    // Sentinel value stored to mark "not interested" (must be negative)
    'not_interested' => -1.0,

    // Maintain vogoo_links incrementally on every rating change.
    // When false, run "sculpt recommender:rebuild-links" after bulk imports.
    'direct_links' => false,
    'direct_slope' => true,
];

Database credentials are read from config/database.php, which is shared with other Canvas packages. The Sculpt provider lists both files as config sources in composer.json.

direct_links and direct_slope are independent switches that control which columns of vogoo_links are maintained in real time:

FlagPowersCounts
direct_linksgetLinkedItems(), memberGetRecommendedItems()Co-occurrence — liked pairs only
direct_slopegetSlopeItems(), memberPredict(), memberPredictAll()All rated pairs

When both are false, vogoo_links is read-only at runtime and must be rebuilt manually after bulk rating imports.

Database Schema

vogoo_ratings

ColumnTypeDescription
member_idINT UNSIGNEDYour application's user ID
product_idINT UNSIGNEDYour application's product/item ID
categoryINT UNSIGNEDCategory grouping (default: 1)
ratingFLOAT0.0–1.0, or -1.0 for "not interested"
tsDATETIMELast updated timestamp

vogoo_links

ColumnTypeDescription
item_id1INT UNSIGNEDFirst item in the pair
item_id2INT UNSIGNEDSecond item in the pair
categoryINT UNSIGNEDCategory grouping
cntINTCo-occurrence count (used by item-based CF)
diff_slopeFLOATAccumulated Slope One rating differential
Note: The primary key on vogoo_links is (item_id1, item_id2, category). This also serves as the unique constraint required by the rebuild command's upsert. No separate unique index is needed.

RecommendationEngine

Handles all rating reads and writes. When direct_links or direct_slope is enabled, write operations also update vogoo_links incrementally.

Recording Ratings

// Explicit rating — must be in [0.0, 1.0]
$engine->setRating($memberId, $productId, 0.8);

// Implicit rating from a purchase (1.0) or a click (0.7, or +0.01 if already rated)
$engine->automaticRating($memberId, $productId, purchase: true);
$engine->automaticRating($memberId, $productId, purchase: false);

// Mark as "not interested"
$engine->setNotInterested($memberId, $productId);

Reading Ratings

// Single rating — returns ['rating' => float, 'ts' => string] or []
$engine->getRating($memberId, $productId);

// Include "not interested" entries
$engine->getRating($memberId, $productId, notInterested: true);

// All ratings for a member, sorted by rating descending
$engine->memberRatings($memberId, orderByRating: true, ascending: false);

// Member statistics
$engine->memberNumRatings($memberId);
$engine->memberAverageRating($memberId);

// Product statistics
$engine->productNumRatings($productId);
$engine->productAverageRating($productId);
$engine->productRatings($productId);

Deleting Ratings

$engine->deleteRating($memberId, $productId);
$engine->deleteMember($memberId);    // removes all ratings for this member
$engine->deleteProduct($productId);  // removes all ratings for this product
Note: When direct_links or direct_slope is enabled, deleteMember() and deleteProduct() iterate over each rating individually to keep vogoo_links consistent. For large datasets, disable incremental updates and run a full rebuild instead.

ItemRecommender

Provides item-based CF and Slope One recommendations for both logged-in members and anonymous visitors.

Item-based CF

// Products that co-occur most frequently with a given product
$recommender->getLinkedItems($productId);
$recommender->getLinkedItems($productId, limit: 10);

// Recommended unrated products for a member, ordered by weighted co-occurrence
$recommender->memberGetRecommendedItems($memberId);
$recommender->memberGetRecommendedItems($memberId, limit: 8);

// Restrict to a specific set of product IDs (e.g. current stock)
$recommender->memberGetRecommendedItems($memberId, filter: $availableIds, limit: 8);

// "Why we recommend this" — member's rated products linked to the given product
$recommender->memberGetReasons($memberId, $productId);

Slope One

// Predicted rating for a single product
$predicted = $recommender->memberPredict($memberId, $productId);  // float|null

// All unrated products with predicted ratings, sorted best-first
$predictions = $recommender->memberPredictAll($memberId);
// [['product_id' => 42, 'rating' => 0.87], ...]

// Items ranked by average slope one diff relative to a product
$similar = $recommender->getSlopeItems($productId, minLinks: 5, limit: 10);
// [['product_id' => 17, 'diff' => 0.12], ...]

Anonymous Visitors

For visitors without an account, hold ratings in a VisitorContext and pass it to the visitor methods. Persist the context across requests via session serialisation.

// Create and populate a visitor context
$visitor = new VisitorContext($config);
$visitor->setRating($productId, 0.9);
$visitor->setNotInterested($otherProductId);

// Item-based CF for a visitor
$recommended = $recommender->visitorGetRecommendedItems($visitor, limit: 8);
$reasons     = $recommender->visitorGetReasons($visitor, $productId);

// Slope One for a visitor
$predicted     = $recommender->visitorPredict($visitor, $productId);  // float|null
$allPredicted  = $recommender->visitorPredictAll($visitor, limit: 8);

UserSimilarity

Computes member-to-member similarity scores using a weighted mean-squared-error algorithm. Similarity ranges from 0 (no overlap or completely different taste) to 100 (identical).

$similarity = $userSimilarity->memberSimilarity($memberId1, $memberId2); // int 0–100

// Neighbours sorted by similarity, filtered by minimum score
$neighbours = $userSimilarity->getNeighbours($memberId, minSimilarity: 10, limit: 20);
// [['member_id' => 7, 'similarity' => 84], ...]

// Recommendations based on what similar members have liked
$recommended = $userSimilarity->memberGetRecommendedItems($memberId, minSimilarity: 20, limit: 8);
Performance: getNeighbours() computes a similarity score against every member who shares at least one rated product. For catalogues with many members, pre-compute and cache neighbour lists offline rather than calling this on every request.

VisitorContext

An in-memory rating store for anonymous visitors. All operations are category-scoped. Serialise to session to persist across requests.

$visitor = new VisitorContext($config);

$visitor->setRating($productId, 0.9);
$visitor->setRating($productId, 0.5, category: 2);  // explicit category
$visitor->setNotInterested($productId);
$visitor->removeRating($productId);

$visitor->getRatings();                // all ratings in default category
$visitor->getRatedProductIds();        // product IDs only
$visitor->isEmpty();                   // true when no ratings exist

Statistics

$stats->numMembers();          // distinct members with at least one rating
$stats->members();             // array of member IDs
$stats->numProducts();         // distinct rated products
$stats->numRatings();          // total genuine ratings
$stats->numLinks();            // item pairs in vogoo_links

// Most-rated products: [['product_id' => int, 'num_ratings' => int], ...]
$stats->mostRatedProducts(limit: 10);

// Top-rated products: [['product_id' => int, 'avg_rating' => float], ...]
$stats->topRatedProducts(limit: 10, minRatings: 5);

Multi-category Support

Every method accepts an optional ?int $category parameter. When omitted it falls back to the default set in RecommendationConfig. To serve multiple independent catalogues, either pass the category explicitly on each call, or instantiate separate config objects per catalogue:

$booksConfig  = new RecommendationConfig(category: 1);
$moviesConfig = new RecommendationConfig(category: 2);

$booksEngine  = new RecommendationEngine($connection, $booksConfig);
$moviesEngine = new RecommendationEngine($connection, $moviesConfig);

Sculpt CLI Commands

CommandDescription
sculpt recommender:initPublish config/recommender.php to the project root
sculpt recommender:init-dbCreate vogoo_ratings and vogoo_links tables
sculpt recommender:init-db --forceDrop and recreate existing tables (all data will be lost)
sculpt recommender:rebuild-linksRebuild vogoo_links from all ratings data
sculpt recommender:rebuild-links --category=NRebuild a single category only

Ratings Reference

ValueMeaningSet via
1.0Purchased / maximum interestautomaticRating(purchase: true)
0.7Clicked — first viewautomaticRating(purchase: false)
0.7 + 0.01 per clickIncremental interest from repeat clicksautomaticRating(purchase: false)
0.0 – 1.0Explicit ratingsetRating()
-1.0Not interestedsetNotInterested()