This page is a case study for the grouprec toolkit: a group recommendation pipeline
built end-to-end from the library's public API. Everything shown is real — no mock score
matrices — computed offline once and then baked into this single self-contained file
(no server, no API calls at view time).
1 · The real framework calls
Every ranking you see is produced by a real grouprec call — the
aggregators through GroupRecommender, the deep model through
GroupIM.group_scores (both steered by the per-member weights):
data = gr.datasets.load("ml-latest-small")
groups = gr.groups.synthetic(data, kind="divergent", size=3, n=3)
gints = gr.groups.derive_group_interactions(data, groups) # per-group signal (majority, overridable)
ease = EASE(reg=200.0).fit(data)
# results-aggregation (utilitarian / fairness), steered by member weights:
rec = GroupRecommender(ease, EPFuzzDAAggregator(member_weights=w)); rec.dataset_ = data
rec.recommend(members, k=5, candidates=cands)
# profile-aggregation deep model, steered by member weights:
gim = GroupIM(groups, gints).fit(data)
gim.group_scores(members, cands, member_weights=w)
This is the core part, not the whole script. The full generator
(grouprec.inspector.build, ~400 lines) wraps those calls with: parsing MovieLens
movies.csv for titles/genres; candidate sampling; a Top-K sparse autoencoder for the
latent concepts (adapted from an external repo — not part of grouprec); and baking a
125-point member-weight grid into JSON. So the framework does the recommendation and the
group-interaction derivation; the script does data prep, the SAE explanation, and packaging.
2 · How the data is shipped (offline → one file)
- Fit the recommenders; for each group call the framework over a 125-point member-weight grid
(
GroupRecommender.recommend orderings for the aggregators, real
GroupIM.group_scores forward passes for the deep model), plus the SAE concepts
and the displayed subsets.
- Assemble one Python
dict and serialise it with json.dumps.
- Substitute that JSON into a placeholder (
const DATA = /*…*/;) in an HTML template
→ one self-contained file (~1 MB; the JSON is a single long line, which is what makes the
file large — the generation logic itself is ~400 lines).
- In your browser, the page reads
DATA and renders/re-ranks entirely client-side;
a slider just looks up the nearest precomputed grid point.
3 · Synthetic groups
Movielens latest small was chosen due to high familiarity of the items, however, no groups were available in the dataset => synthetic groups were generated.
Membership comes from gr.groups.synthetic in three regimes — similar,
divergent, outlier (three each), badged with the measured mean pairwise rating
correlation. A group's interactions are derived, not simulated, by the framework call
gr.groups.derive_group_interactions: by default an item is a group's
consensus item if rated ≥4 by ≥2 of the 3 members — a deterministic function of the real
ratings — and the rule is overridable with a predicate (e.g. unanimity, or "any member").
4 · Algorithms & faithful interactivity
The slider is each member's importance weight (normalised to 100%). Outputs are precomputed on a
{0,¼,½,¾,1}³ = 125-point grid and the slider snaps to the nearest point, so every
ranking is a genuine output:
- Weighted average —
GroupRecommender + grouprec
WeightedAverageAggregator(member_weights=w).
- EP-FuzzDA —
GroupRecommender + grouprec
EPFuzzDAAggregator(member_weights=w) (fairness; the weight is each member's target share).
- GroupIM — the framework's
GroupIM.group_scores(members, items, member_weights=w),
a real forward pass that reweights the model's attention pooling
(α'm ∝ wm·αm), reducing to the native model at equal
weights. Steering is in the model's pooling — not via the SAE.
5 · Latent concepts (how the SAE was adopted)
We reuse a Top-K sparse autoencoder (standardised input, unit-norm decoder, ReLU encoder,
hard top-K=6, MSE + small L1) and fit it on GroupIM's item embeddings — specifically the rows
of its encoder.user_predictor weight, one vector per movie. Each latent feature is
named by the dominant genre of its top-activating movies, turning opaque dimensions into
readable concepts. A member is tagged with a concept because the films they watched activate it; the
films listed are the concept's global exemplars, so they may differ from the member's own
history. The SAE is only an explanation of members.
Why the encoder head, and why "lift". Fitting the SAE on group_predictor
instead gave every member the same concepts: that head is trained on only as many target
distributions as there are groups, so its item space collapses onto popularity (one direction
explained ~66% of the variance, correlating ≈−0.68 with item popularity) and the SAE simply
decomposed popularity. The encoder.user_predictor head is pretrained over
all users, so it carries real taste structure. Independently, ranking a member's concepts by
raw mean activation returns whatever is globally strongest, so we rank by lift — the member's
mean activation divided by the global mean, i.e. what is distinctive about that member.
6 · Every displayed subset is deterministic
| Subset | Rule (ties: ascending index, argsort kind="stable") |
| Member history (3) | top-3 by rating; ties by item id |
| Candidate pool (50) | consensus positive + 49 negatives sampled uniformly (per-group seed) from items no member rated |
| Recs received (3) | member's top-3 candidates by EASE score |
| Top-5 recs | top-5 by group score / selection order |
| Latent concepts (3) | SAE features with the highest lift over the member's items (member mean activation ÷ global mean, i.e. most distinctive) |
| Concept exemplars (3) | items with highest activation for the feature |
Reproduce with grouprec-build-inspector. Full write-up:
docs/INSPECTOR.md in the repository.