feat(taste): era/decade taste facet — #1530
Milestone #160 Opt 2 (era half). A third taste facet alongside artists + genre tags: signed weights over decade buckets ("1990s") derived from albums.release_date, rebuilt daily and scored into the taste match. - Migration 0045: taste_profile_eras table (mirrors taste_profile_tags) + taste_tuning.era_scale column (DEFAULT 0.5). - Build side (internal/taste): Config.EraScale ([0,1] damper, mirrors EnrichedTagScale), accumulate folds each play/like's decade at base*EraScale, persist atomic-replaces the era rows. - Scorer (internal/recommendation): TasteProfile gains an era term (own tanh scale + additive 0.15 share so it never weakens the existing artist/tag signal when a track is undated); candidate queries return album release_date; decadeOf mirrors the builder helper. - Tuning lab: era_scale threaded through recsettings + admin API + web card (auto-renders the new row) + Go/web tests. Mood facet deferred to #1534 (partial enrichment coverage + needs candidate-side enriched-tag loading). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -10,24 +11,34 @@ import (
|
||||
)
|
||||
|
||||
// Taste-match tuning. The taste profile (written by internal/taste) holds
|
||||
// signed, unbounded artist/tag weights; these scales squash them into a
|
||||
// signed, unbounded artist/tag/era weights; these scales squash them into a
|
||||
// bounded [-1, +1] match via tanh, so one outlier artist can't compress the
|
||||
// rest toward zero (as a per-user max-normalisation would). A weight at the
|
||||
// scale value maps to tanh(1) ≈ 0.76 — "clearly a preference."
|
||||
//
|
||||
// The era term (#1530) is added on TOP of artist+tag (shares don't sum to 1)
|
||||
// so it's a pure decade nudge that never weakens the existing artist/tag
|
||||
// signal when a track is undated; clampUnit bounds the combined result. Its
|
||||
// share is small — a decade is a coarse signal — and re-bakeable if the lab
|
||||
// shows it should carry more.
|
||||
const (
|
||||
tasteArtistScale = 4.0
|
||||
tasteTagScale = 3.0
|
||||
tasteEraScale = 4.0
|
||||
tasteArtistShare = 0.7
|
||||
tasteTagShare = 0.3
|
||||
tasteEraShare = 0.15
|
||||
tasteProfileLimit = 2000 // read cap; profiles are size-capped on write
|
||||
)
|
||||
|
||||
// TasteProfile is the read-side view of a user's learned taste: signed
|
||||
// weights over artists and genre tags. The zero value (and any unknown
|
||||
// artist/tag) contributes 0, so cold-start users get no taste effect.
|
||||
// weights over artists, genre tags, and decade/era buckets. The zero value
|
||||
// (and any unknown artist/tag/era) contributes 0, so cold-start users get no
|
||||
// taste effect.
|
||||
type TasteProfile struct {
|
||||
artists map[pgtype.UUID]float64
|
||||
tags map[string]float64
|
||||
eras map[string]float64
|
||||
}
|
||||
|
||||
// LoadTasteProfile reads the user's taste profile from the taste_profile_*
|
||||
@@ -46,9 +57,16 @@ func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (
|
||||
if err != nil {
|
||||
return TasteProfile{}, err
|
||||
}
|
||||
eras, err := q.ListTasteProfileErasForUser(ctx, dbq.ListTasteProfileErasForUserParams{
|
||||
UserID: userID, Limit: tasteProfileLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return TasteProfile{}, err
|
||||
}
|
||||
p := TasteProfile{
|
||||
artists: make(map[pgtype.UUID]float64, len(arts)),
|
||||
tags: make(map[string]float64, len(tags)),
|
||||
eras: make(map[string]float64, len(eras)),
|
||||
}
|
||||
for _, a := range arts {
|
||||
p.artists[a.ArtistID] = a.Weight
|
||||
@@ -56,13 +74,16 @@ func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (
|
||||
for _, t := range tags {
|
||||
p.tags[t.Tag] = t.Weight
|
||||
}
|
||||
for _, e := range eras {
|
||||
p.eras[e.Era] = e.Weight
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Match scores a candidate track's fit to the profile in [-1, +1]: a blend of
|
||||
// the artist's taste weight and the average of its genre tags' weights, each
|
||||
// tanh-squashed. Absent artist/tags contribute 0.
|
||||
func (p TasteProfile) Match(artistID pgtype.UUID, genre *string) float64 {
|
||||
// the artist's taste weight, the average of its genre tags' weights, and its
|
||||
// decade/era weight, each tanh-squashed. Absent artist/tags/era contribute 0.
|
||||
func (p TasteProfile) Match(artistID pgtype.UUID, genre *string, releaseDate pgtype.Date) float64 {
|
||||
a := math.Tanh(p.artists[artistID] / tasteArtistScale)
|
||||
|
||||
var tg float64
|
||||
@@ -76,7 +97,22 @@ func (p TasteProfile) Match(artistID pgtype.UUID, genre *string) float64 {
|
||||
tg = math.Tanh((sum / float64(len(tags))) / tasteTagScale)
|
||||
}
|
||||
}
|
||||
return clampUnit(tasteArtistShare*a + tasteTagShare*tg)
|
||||
|
||||
var er float64
|
||||
if decade := decadeOf(releaseDate); decade != "" {
|
||||
er = math.Tanh(p.eras[decade] / tasteEraScale)
|
||||
}
|
||||
return clampUnit(tasteArtistShare*a + tasteTagShare*tg + tasteEraShare*er)
|
||||
}
|
||||
|
||||
// decadeOf maps an album release date to a decade bucket ("1990s"), or "" for
|
||||
// an absent date. Mirrors the taste builder's helper (kept local to avoid a
|
||||
// cross-package dependency) so a candidate's era is derived exactly as learned.
|
||||
func decadeOf(d pgtype.Date) string {
|
||||
if !d.Valid {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%ds", (d.Time.Year()/10)*10)
|
||||
}
|
||||
|
||||
// clampUnit constrains x to [-1, 1].
|
||||
|
||||
Reference in New Issue
Block a user