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.
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.0marks explicit disinterest. - Derived item pairs — Stored in
vogoo_links. Each directed pair has an independentliked_countfor members who liked both items,slope_countfor members who genuinely rated both items, anddiff_slopefor the sum of rating(item_id2) minus rating(item_id1) over those genuine ratings. Rows remain while either count is positive. - 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_linksincrementally 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 in the current schema, populate vogoo_links with a full rebuild:
sculpt recommender:rebuild-links
Pause rating writes during a rebuild. It computes both pair measures regardless of direct_links and direct_slope, stages each category before replacing its rows in one transaction, and clears rows for categories whose ratings have been deleted. Use --category=N to rebuild one category, including an empty one.
Upgrading from the legacy cnt schema
Back up both vogoo_ratings and vogoo_links, pause rating writes, run packages/recommender/migrations/2026-10-independent-pair-counts.sql, and then run sculpt recommender:rebuild-links before resuming writes. The old cnt value combines the two algorithms and cannot be split reliably; the migration clears derived rows but preserves ratings. For rollback, stop writes, restore both table backups together, and return to the previous package version. Do not use recommender:init-db --force for an upgrade because it deletes ratings.
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" (fixed at -1.0)
'not_interested' => -1.0,
// Maintain liked_count incrementally.
'direct_links' => false,
// Maintain slope_count and diff_slope incrementally.
'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:
| Flag | Powers | Counts |
|---|---|---|
direct_links | getLinkedItems(), memberGetRecommendedItems() | Co-occurrence — liked pairs only |
direct_slope | getSlopeItems(), memberPredict(), memberPredictAll() | All rated pairs |
These flags control incremental writes only; a rebuild always computes both measures. When both are false, derived rows stay unchanged until a rebuild. Rebuild once before enabling either flag on an existing ratings store.
Database Schema
vogoo_ratings
| Column | Type | Description |
|---|---|---|
member_id | INT UNSIGNED | Your application's user ID |
product_id | INT UNSIGNED | Your application's product/item ID |
category | INT UNSIGNED | Category grouping (default: 1) |
rating | FLOAT | 0.0–1.0, or -1.0 for "not interested" |
ts | DATETIME | Last updated timestamp |
vogoo_links
| Column | Type | Description |
|---|---|---|
item_id1 | INT UNSIGNED | First item in the pair |
item_id2 | INT UNSIGNED | Second item in the pair |
category | INT UNSIGNED | Category grouping |
liked_count | INT UNSIGNED | Members who rated both items at or above the like threshold |
slope_count | INT UNSIGNED | Members who genuinely rated both items |
diff_slope | FLOAT | Sum of rating(item_id2) minus rating(item_id1) over genuine rating pairs |
vogoo_links is (item_id1, item_id2, category). Category and reverse-item indexes support recommendation lookups. Statistics::numLinks() counts directed rows with either measure.
RecommendationEngine
Handles all rating reads and writes. When direct_links or direct_slope is enabled, write operations also update vogoo_links incrementally.
setRating() accepts finite values from 0.0 to 1.0 or exactly -1.0 for disinterest; invalid ratings or negative IDs return false. Invalid configuration and visitor ratings throw InvalidArgumentException. Categories must be nonnegative.
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
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);
Detailed Results and Cold Start
The existing item-based methods continue to return product IDs. The opt-in detailed methods return RecommendationResult objects with itemId, score, strategy, and contributingItemIds. With enough genuine rating history, item_links scores sum liked_count × (source rating − like threshold). For shorter histories, top_rated scores are average genuine ratings. Scores should be compared within the same strategy.
$results = $recommender->memberRecommendationsDetailed($memberId, filter: $availableIds, limit: 8);
// $results[0]->itemId, ->score, ->strategy, ->contributingItemIds
The detailed methods accept minHistory (default 1) and minRatings (default 2). The fallback excludes already rated and explicitly rejected items, respects categories and allowed-item filters, and requires at least minRatings genuine ratings per candidate. Allowed-item filters apply before SQL limits; lists over 500 IDs use a temporary indexed table loaded in batches.
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], ...]
Slope One uses slope_count and diff_slope only. Disinterest does not contribute to predictions. memberPredict() estimates a single item even if the member has rated it; memberPredictAll() returns only unrated items.
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);
// Detailed recommendations include score, strategy, and contributing item IDs
$visitorResults = $recommender->visitorRecommendationsDetailed($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);
getNeighbours() aggregates candidate overlaps in one query, and recommendation ratings are read in batches of 500 neighbours. Zero-similarity neighbours are excluded even when minSimilarity: 0 is requested.
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(); // directed pairs with liked or Slope One data
// 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
| Command | Description |
|---|---|
sculpt recommender:init | Publish config/recommender.php to the project root |
sculpt recommender:init-db | Create vogoo_ratings and vogoo_links tables |
sculpt recommender:init-db --force | Drop and recreate existing tables (all data will be lost) |
sculpt recommender:rebuild-links | Rebuild both measures from all ratings and clear stale categories |
sculpt recommender:rebuild-links --category=N | Rebuild a single category only |
Ratings Reference
| Value | Meaning | Set via |
|---|---|---|
1.0 | Purchased / maximum interest | automaticRating(purchase: true) |
0.7 | Clicked — first view | automaticRating(purchase: false) |
0.7 + 0.01 per click | Incremental interest from repeat clicks | automaticRating(purchase: false) |
0.0 – 1.0 | Explicit rating | setRating() |
-1.0 | Not interested | setNotInterested() |