Group Recommendation Inspector — how it's built¶
The inspector (group_rec_inspector.html) is a single
self-contained page generated by the grouprec-build-inspector console script
(module grouprec.inspector.build;
installed with the [torch] extra).
Its point is that everything is real and obtained with a handful of grouprec
calls — no mock score matrices. This page documents the framework reuse and exactly
how every displayed subset is selected.
Everything is a framework call¶
import grouprec as gr
from grouprec import GroupRecommender
from grouprec.backends import EASE
from grouprec.aggregators import WeightedAverageAggregator, EPFuzzDAAggregator
from grouprec.models import GroupIM
# 1. dataset processing (download + parse + vocab) — one call
data = gr.datasets.load("ml-latest") # redistribution-permitting MovieLens release
data = gr.datasets.k_core(data, k=20) # 331k->204k users, 83k->23k items (framework call)
# 2. synthetic group generation — random / similar / divergent / outlier, size K
similar = gr.groups.synthetic(data, kind="similar", size=3, n=3, seed=0)
divergent = gr.groups.synthetic(data, kind="divergent", size=3, n=3, seed=0)
outlier = gr.groups.synthetic(data, kind="outlier", size=3, n=3, seed=0)
# 2b. derive each group's item signal from members' individual likes (majority rule,
# overridable via a predicate) — the format the deep model trains on
groups = divergent
group_interactions = gr.groups.derive_group_interactions(data, groups)
# a group, its per-member weights, and the items to rank for it
members, w = groups[0], [0.6, 0.3, 0.1]
cands = gr.groups.candidate_items(data, members)
# 3a. group recommendation via aggregators (results-aggregation), steered by member weights
rec = GroupRecommender(EASE(reg=200.0), WeightedAverageAggregator(member_weights=w))
rec.fit(data)
rec.recommend(members, k=5, candidates=cands) # ranked item ids
# swap the aggregator for fairness:
rec = GroupRecommender(EASE(reg=200.0), EPFuzzDAAggregator(member_weights=w)).fit(data)
# 3b. group recommendation via a deep model (profile-aggregation), steered by member weights
gim = GroupIM(groups, group_interactions, epochs=40).fit(data)
gim.recommend(members, k=5, candidates=cands, member_weights=w)
gim.group_scores(members, cands, member_weights=w) # per-item scores (used by the demo)
What is genuinely the framework vs. demo glue. Every ranking the inspector shows is a
real grouprec call: the aggregators go through GroupRecommender (we keep one instance with
a pre-fitted EASE base and swap its weighted aggregator per weight combo), and the deep model
through GroupIM.group_scores(member_weights=...), and each group's item signal is derived by
the framework call gr.groups.derive_group_interactions. Around those calls the generator adds,
as ordinary application code: parsing MovieLens movies.csv for titles/genres; candidate
sampling; the Top-K SAE (adapted from umap2026/sae.py, external to grouprec); and baking a
125-point weight grid into JSON. So the framework does the recommendation and the
group-interaction derivation; the script does data prep, the SAE explanation layer, and packaging
(~400 lines). The snippet above is the essence, not the whole script.
Groups¶
- Built on the 20-core of MovieLens
ml-latest(204,257 users / 23,290 items / 32.2M ratings). The k-core is a framework call (grouprec.datasets.preprocess.k_core) and is what brings EASE'sn_items x n_itemsGram back into memory; the user count needs no reduction because group sampling now goes through the lazy similarity (seedocs/scaling.md). - Membership is produced by
gr.groups.syntheticin three regimes — similar, divergent, outlier — three groups each (nine total). Each is tagged with its regime and the measured mean pairwise rating correlationr. - A group's interactions are derived, not simulated, by
gr.groups.derive_group_interactions: by default an item is a consensus item if rated>= 4by>= 2of the three members (a deterministic function of the real ratings), and the rule is overridable with a predicate (e.g. unanimity, or "any member"). We keep groups with>= 3consensus items; deep models train on them and the first consensus item is the marked positive (✓).
How every displayed subset is selected (deterministic)¶
| Subset shown | Rule |
|---|---|
| Member item history (3 films) | top-3 by rating; ties broken by ascending item id |
| Candidate pool (50) | the consensus positive + 49 negatives sampled uniformly without replacement (per-group seed) from items no member rated |
| Recs received (3 per member) | the member's top-3 candidates by EASE score (descending; stable ties) |
| Top-5 recommendations | top-5 by group score (score-based) / by the aggregator's selection order (EP-FuzzDA); stable ties |
| Latent concepts (3 per member) | the SAE features with the highest lift over the member's rated items — member mean activation ÷ global mean, i.e. what is distinctive about the member rather than globally strongest (stable ties) |
| Concept exemplars (3 films) | the items with highest activation for that SAE feature (stable ties); the feature's genre label is the dominant genre of its top-8 items |
"Stable ties" = numpy.argsort(..., kind="stable"), i.e. ties resolved by ascending index — reproducible across runs.
Interactivity (kept faithful on a static page)¶
The per-member slider is each member's importance weight, normalised to sum to 100%
(the percentage shown is the effective share). Because a static page can't run PyTorch /
the greedy aggregators live, weight-dependent outputs are precomputed on a grid
({0, ¼, ½, ¾, 1}³ = 125 combinations per group per method) and the slider snaps to the
nearest grid point — so every ranking shown is a genuine framework output:
- Weighted average / EP-FuzzDA —
GroupRecommender.recommendwith the frameworkWeightedAverageAggregator/EPFuzzDAAggregator(member_weights); a selection ordering. - GroupIM —
GroupIM.group_scores(members, items, member_weights=…), a real forward pass that reweights the model's attention pooling (α'_m ∝ w_m·α_m), reducing to the native model at equal weights.
AGREE is intentionally omitted: its non-linear NCF head makes member-level steering weak. Least-misery / most-pleasure / Borda are omitted because they are weight-agnostic; LTP and RLProp because, in single-shot top-k, LTP ≈ EP-FuzzDA and the RLProp class does not expose member weights.
Attribution¶
- Aggregators: a member's contribution to an item is its share of that item's
aggregated relevance,
w_m·s_m(i) / Σ_m w_m·s_m(i)(how much the chosen item caters to the member). - GroupIM: the model's native pooling attention, scaled by the slider weight.
Latent concepts (how the SAE was adopted)¶
We reuse the Top-K sparse autoencoder from umap2026/sae.py and adapt it minimally:
standardised input, an L2-unit-norm decoder, ReLU encoder, and a hard top-K=6 activation
(so each embedding is explained by at most 6 latent atoms), trained with MSE + a small L1
penalty. We fit it on GroupIM's item embeddings — specifically the rows of its
encoder.user_predictor weight matrix, i.e. one learned vector per movie. Each latent
feature is then named by the dominant genre of its top-activating movies (using the
movies.csv genre labels), turning opaque dimensions into readable concepts (e.g.
"Drama / Musical"). A member is tagged with a concept because the films they watched activate
it; the films listed beside a concept are its global exemplars, so they need not be in the
member's own history. The SAE is only an explanation of members — it is not part of the
models' steering.
Why the encoder head, and why "lift". We originally fit the SAE on group_predictor
and every member came out with the same concepts. The reason: group_predictor is trained
on only |groups| target distributions (9 here), so its item space collapses onto
popularity — a single direction explained ~66% of the variance and correlated ≈ −0.68 with
item popularity, and the SAE simply decomposed popularity (whose top items are, by
construction, the most common genres). The encoder.user_predictor head is pretrained over
all users, so it carries actual taste structure. Independently, selecting a member's
concepts by raw mean activation returns whatever is globally strongest, so we select by
lift (the member's mean activation ÷ the global mean), i.e. what is distinctive about
that member. Together these took the demo from 3 to 16 distinct concept sets over 27 members
— members of similar groups still share concepts (as they should), while divergent
groups' members visibly differ.
How the data is shipped (offline → one file)¶
Everything above is computed once, offline, by grouprec-build-inspector, then baked into a
single HTML — there is no server and no API call at view time:
- The script fits the recommenders and computes, per group: the per-member score matrix, the 125-point weight grids (real aggregator orderings / real GroupIM forward passes), the SAE concepts, and the displayed subsets.
- It assembles one Python
dict(payload) and serialises it withjson.dumps. - That JSON is string-substituted into a placeholder (
const DATA = /*__DATA__*/;) inside an HTML template, producing one self-contained file (~1 MB; the JSON is a single long line). - In the browser, the page reads
DATAand renders/re-ranks entirely client-side; dragging a slider just looks up the nearest precomputed grid point.
So the artifact is "compute with grouprec offline → embed as JSON → static page". Reproduce it
with grouprec-build-inspector (after pip install grouprec[torch]), or from a repo checkout
with python scripts/build_inspector.py. The defaults are the published page's configuration
(--dataset ml-latest --kcore 20); the download and the EASE fit take a while, so pass
--small to build a much smaller page off ml-latest-small when you just want a quick look
or are adding a method. (The generation logic is ~400 lines of Python on the public API; the
embedded data, not lines of code, is what makes the file large.)