Skip to content

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's n_items x n_items Gram back into memory; the user count needs no reduction because group sampling now goes through the lazy similarity (see docs/scaling.md).
  • Membership is produced by gr.groups.synthetic in three regimes — similar, divergent, outlier — three groups each (nine total). Each is tagged with its regime and the measured mean pairwise rating correlation r.
  • A group's interactions are derived, not simulated, by gr.groups.derive_group_interactions: by default an item is a consensus item if rated >= 4 by >= 2 of 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 >= 3 consensus 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-FuzzDAGroupRecommender.recommend with the framework WeightedAverageAggregator / EPFuzzDAAggregator (member_weights); a selection ordering.
  • GroupIMGroupIM.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:

  1. 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.
  2. It assembles one Python dict (payload) and serialises it with json.dumps.
  3. 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).
  4. In the browser, the page reads DATA and 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.)