feat(taste): fold enriched folksonomy tags into the profile (#1490 Step 3)
The taste recompute's tag facet now unions the cached track_tags (MusicBrainz/Last.fm folksonomy tags) alongside raw ID3 genre, so a coarse "Rock" gains "post-punk / shoegaze / melancholic". - taste_profile.sql: ListPlayEngagementInputsForUser + ListLikedTrackTasteInputsForUser now return track_id to key the enriched-tag lookup. - accumulate(): for each play, fold its track's enriched tags weighted by engagement × tag.weight × EnrichedTagScale; for each liked track, by the tag-like bonus × tag.weight × scale. A track with no cached tags contributes genre only (graceful). - New Config.EnrichedTagScale (default 0.5) — enriched tags augment the ID3 signal without swamping it; 0 = genre-only. Flows through recsettings.TasteConfig() (starts from DefaultConfig). Promoting it into the admin tuning lab is a small follow-up. Unit-tested the pure foldEnrichedTags helper (overlap accumulation + scale=0 disable). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+67
-13
@@ -36,6 +36,16 @@ type Config struct {
|
||||
TrackLikeBonus float64
|
||||
TagLikeBonus float64
|
||||
|
||||
// EnrichedTagScale weights folksonomy tags (from the track_tags cache,
|
||||
// #1490) relative to a track's raw ID3 genre when both fold into the tag
|
||||
// facet. Each enriched tag contributes base × tag.weight × this scale,
|
||||
// where base is the play's decayed engagement (or the tag-like bonus for
|
||||
// a liked track) and tag.weight is the normalized folksonomy strength in
|
||||
// [0,1]. Below 1 so the richer-but-noisier enriched vocabulary augments
|
||||
// the ID3 genre signal without swamping it. 0 disables the enriched
|
||||
// contribution entirely (falls back to genre-only).
|
||||
EnrichedTagScale float64
|
||||
|
||||
// ArtistFloor / TagFloor clamp how negative a single entity's weight may
|
||||
// go. Aggregation already protects an artist the user likes (one skip
|
||||
// nets out against many good plays); the floor additionally bounds the
|
||||
@@ -58,19 +68,20 @@ type Config struct {
|
||||
// DefaultConfig returns the tuned starting configuration.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Engagement: DefaultEngagementParams(),
|
||||
HalfLifeDays: 75,
|
||||
WindowDays: 270,
|
||||
ArtistLikeBonus: 3.0,
|
||||
TrackLikeBonus: 1.0,
|
||||
TagLikeBonus: 0.5,
|
||||
ArtistFloor: -3.0,
|
||||
TagFloor: -3.0,
|
||||
WeightEpsilon: 0.05,
|
||||
MaxArtists: 500,
|
||||
MaxNegArtists: 150,
|
||||
MaxTags: 200,
|
||||
MaxNegTags: 60,
|
||||
Engagement: DefaultEngagementParams(),
|
||||
HalfLifeDays: 75,
|
||||
WindowDays: 270,
|
||||
ArtistLikeBonus: 3.0,
|
||||
TrackLikeBonus: 1.0,
|
||||
TagLikeBonus: 0.5,
|
||||
EnrichedTagScale: 0.5,
|
||||
ArtistFloor: -3.0,
|
||||
TagFloor: -3.0,
|
||||
WeightEpsilon: 0.05,
|
||||
MaxArtists: 500,
|
||||
MaxNegArtists: 150,
|
||||
MaxTags: 200,
|
||||
MaxNegTags: 60,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +132,19 @@ func accumulate(
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load play engagement: %w", err)
|
||||
}
|
||||
// Enriched folksonomy tags keyed by track (#1490) — folded into the tag
|
||||
// facet alongside raw ID3 genre so a coarse "Rock" gains the cached
|
||||
// "post-punk / shoegaze / melancholic" vocabulary. Window matches the
|
||||
// play window; a track with no cached tags simply contributes genre only.
|
||||
playedTagRows, err := q.ListPlayedTrackTagsForUser(ctx, dbq.ListPlayedTrackTagsForUserParams{
|
||||
UserID: userID,
|
||||
Column2: int32(cfg.WindowDays),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load played track tags: %w", err)
|
||||
}
|
||||
playedTags := groupTagsByTrack(playedTagRows)
|
||||
|
||||
artistW := make(map[pgtype.UUID]float64)
|
||||
tagW := make(map[string]float64)
|
||||
for _, p := range plays {
|
||||
@@ -129,17 +153,24 @@ func accumulate(
|
||||
for _, tag := range splitGenres(p.Genre) {
|
||||
tagW[tag] += e
|
||||
}
|
||||
foldEnrichedTags(tagW, playedTags[p.TrackID], e, cfg.EnrichedTagScale)
|
||||
}
|
||||
|
||||
likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
|
||||
}
|
||||
likedTagRows, err := q.ListLikedTrackTagsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load liked track tags: %w", err)
|
||||
}
|
||||
likedTags := groupTagsByTrack(likedTagRows)
|
||||
for _, lt := range likedTracks {
|
||||
artistW[lt.ArtistID] += cfg.TrackLikeBonus
|
||||
for _, tag := range splitGenres(lt.Genre) {
|
||||
tagW[tag] += cfg.TagLikeBonus
|
||||
}
|
||||
foldEnrichedTags(tagW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.EnrichedTagScale)
|
||||
}
|
||||
|
||||
likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID)
|
||||
@@ -152,6 +183,29 @@ func accumulate(
|
||||
return artistW, tagW, nil
|
||||
}
|
||||
|
||||
// groupTagsByTrack buckets flat (track, tag, weight) rows by track id so
|
||||
// each play/like can fold in its track's enriched tags in one lookup.
|
||||
func groupTagsByTrack(rows []dbq.TrackTag) map[pgtype.UUID][]dbq.TrackTag {
|
||||
m := make(map[pgtype.UUID][]dbq.TrackTag)
|
||||
for _, r := range rows {
|
||||
m[r.TrackID] = append(m[r.TrackID], r)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// foldEnrichedTags adds each enriched tag to tagW weighted by
|
||||
// base × tag.weight × scale — base is the play's decayed engagement or the
|
||||
// tag-like bonus, tag.weight is the folksonomy strength in [0,1]. scale=0
|
||||
// disables the enriched contribution (genre-only fallback).
|
||||
func foldEnrichedTags(tagW map[string]float64, tags []dbq.TrackTag, base, scale float64) {
|
||||
if scale == 0 {
|
||||
return
|
||||
}
|
||||
for _, t := range tags {
|
||||
tagW[t.Tag] += base * t.Weight * scale
|
||||
}
|
||||
}
|
||||
|
||||
// persist atomic-replaces the user's profile rows inside one transaction.
|
||||
func persist(
|
||||
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
||||
|
||||
Reference in New Issue
Block a user