import%20marimo%0A%0A__generated_with%20%3D%20%220.23.9%22%0Aapp%20%3D%20marimo.App(width%3D%22full%22)%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%3C!--%20garden%3Apublish%0A%20%20%20%20title%3A%20Dream%20of%20Spotification%0A%20%20%20%20description%3A%20A%20decade%20of%20my%20own%20Spotify%20history%20(~50k%20plays)%2C%20worked%20three%20ways%20%E2%80%94%20sessionized%20listening%2C%20a%203D%20embedding%20of%20taste%20over%20time%2C%20and%20a%20survival%20analysis%20of%20my%20own%20attention%20span.%0A%20%20%20%20order%3A%2030%0A%20%20%20%20--%3E%0A%0A%20%20%20%20%23%20Dream%20of%20Spotification%0A%0A%20%20%20%20A%20decade%20of%20my%20own%20Spotify%20history%20(~50k%20plays)%2C%20worked%20two%20ways%3A%0A%0A%20%20%20%20**Act%20I%20%E2%80%94%20Taste%20over%20time.**%20Build%20a%20*month%20%C3%97%20genre*%20matrix%20of%20listening%0A%20%20%20%20shares%2C%20factor%20it%20with%20**NMF**%20into%20a%20handful%20of%20recurring%20%22archetypes%2C%22%20then%0A%20%20%20%20ask%20a%20supervised%20model%20whether%20the%20archetype-mix%20of%20a%20month%20is%20enough%20to%20guess%0A%20%20%20%20*when*%20that%20month%20happened.%20(It%20is%20%E2%80%94%20a%20model%20can%20date%20a%20random%20listening%20window%0A%20%20%20%20to%20within%20a%20couple%20of%20years%20from%20genre-mix%20alone.)%0A%0A%20%20%20%20**Act%20II%20%E2%80%94%20A%20map%20of%20artists.**%20Forget%20genres%3B%20let%20the%20*listening%20sessions*%0A%20%20%20%20define%20the%20space.%20Two%20artists%20are%20related%20if%20I%20tend%20to%20play%20them%20in%20the%20same%0A%20%20%20%20sitting.%20Turn%20that%20co-occurrence%20into%20an%20**artist%20embedding**%20(PPMI%20%E2%86%92%20SVD%2C%20the%0A%20%20%20%20same%20math%20behind%20word2vec)%2C%20then%20explore%20it%20geometrically%3A%20nearest%20neighbors%2C%0A%20%20%20%20%60A%3AB%3A%3AC%3AX%60%20analogies%20as%20parallel%20vectors%2C%20%22exact%20opposites%2C%22%20and%20a%203D%20map%20of%0A%20%20%20%20the%20whole%20library.%0A%0A%20%20%20%20**Act%20III%20%E2%80%94%20The%20attention%20budget.**%20Spotify%20never%20records%20a%20skip%2C%20but%0A%20%20%20%20%60played_at%60%20marks%20the%20*start*%20of%20a%20play%2C%20so%20the%20gap%20to%20the%20next%20one%20is%20how%20long%0A%20%20%20%20I%20actually%20listened.%20Reconstruct%20abandonment%20from%20timestamp%20arithmetic%2C%20then%20do%0A%20%20%20%20it%20honestly%20with%20**Kaplan-Meier%20survival%20analysis**%20(session-final%20plays%20are%0A%20%20%20%20right-censored)%20and%20find%20a%20constant%20hiding%20under%20a%20decade%20of%20changing%20taste.%0A%0A%20%20%20%20Everything%20reads%20from%20the%20%60your_spotify%60%20mart%20on%20Postgres%20(phlegethon)%20via%20the%0A%20%20%20%20read-only%20%60claude_reader%60%20account.%20Each%20section%20below%20has%20a%20write-up%20of%20the%20math%0A%20%20%20%20and%20how%20to%20read%20its%20output.%0A%0A%20%20%20%20%3E%20**Reactivity%20note%20(marimo)%3A**%20cells%20run%20in%20*dependency*%20order%2C%20not%20top-to-%0A%20%20%20%20%3E%20bottom.%20A%20variable%20may%20only%20be%20defined%20by%20one%20cell%2C%20and%20any%20name%20that%20isn't%0A%20%20%20%20%3E%20underscore-prefixed%20is%20exported%20to%20the%20whole%20notebook.%20That's%20why%20helper%0A%20%20%20%20%3E%20imports%2Ftemporaries%20in%20later%20cells%20are%20written%20as%20%60_PCA%60%2C%20%60_go%60%2C%20%60_tmp%60%2C%20etc.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_()%3A%0A%20%20%20%20%23%20Always%20use%20these%0A%20%20%20%20import%20marimo%20as%20mo%0A%20%20%20%20import%20pandas%20as%20pd%0A%20%20%20%20import%20numpy%20as%20np%0A%20%20%20%20import%20matplotlib.pyplot%20as%20plt%0A%0A%20%20%20%20from%20sklearn.decomposition%20import%20NMF%0A%0A%20%20%20%20%23%20db%20layer%0A%20%20%20%20from%20sqlalchemy%20import%20text%0A%20%20%20%20from%20db%20import%20pg%0A%0A%20%20%20%20WES_ID%20%3D%20%2267640fa18d9ec7f08167a0c1%22%0A%20%20%20%20LINDSEY_ID%20%3D%20%22676aafdcfef6f8fa0e3c161d%22%0A%20%20%20%20SESSION_THRESHOLD%20%3D%20pd.Timedelta(minutes%3D30)%0A%0A%20%20%20%20engine%20%3D%20pg(%22your_spotify%22)%20%23%20data%20lives%20on%20phlegethon%0A%20%20%20%20return%20NMF%2C%20SESSION_THRESHOLD%2C%20WES_ID%2C%20engine%2C%20mo%2C%20np%2C%20pd%2C%20plt%2C%20text%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%20Create%20Sessionized%20Plays%20df%0A%0A%20%20%20%20Pull%20every%20play%20(%60mart.fact_plays%60%2C%20left-joined%20to%20%60mart.dim_artist%60%20for%20human%0A%20%20%20%20names)%2C%20filter%20to%20me%2C%20and%20group%20plays%20into%20**listening%20sessions**.%0A%0A%20%20%20%20**Sessionization.**%20Sort%20plays%20by%20time%2C%20then%20start%20a%20new%20session%20whenever%20the%0A%20%20%20%20gap%20from%20the%20previous%20play%20exceeds%2030%20minutes%3A%0A%0A%20%20%20%20%60%60%60%0A%20%20%20%20gap%20%20%20%20%20%20%20%20%20%3D%20played_at.diff()%20%20%20%20%20%20%20%20%20%20%23%20time%20since%20previous%20play%0A%20%20%20%20new_session%20%3D%20gap%20%3E%2030%20min%20%20%20%20%20%20%20%20%20%20%20%20%20%20%23%20True%20at%20each%20session%20boundary%0A%20%20%20%20session_seq%20%3D%20new_session.cumsum()%20%20%20%20%20%20%23%20running%20count%20of%20boundaries%20%3D%20session%20%23%0A%20%20%20%20%60%60%60%0A%0A%20%20%20%20The%20%60cumsum()%60%20of%20a%20boolean%20is%20the%20classic%20%22sessionize%22%20trick%3A%20every%20%60True%60%0A%20%20%20%20bumps%20the%20counter%2C%20so%20all%20plays%20between%20two%20gaps%20share%20one%20id.%20Sessions%20are%20the%0A%20%20%20%20unit%20of%20*listening%20context*%20%E2%80%94%20they're%20what%20Act%20II's%20artist%20co-occurrence%20is%0A%20%20%20%20built%20from%2C%20so%20this%20cell%20is%20upstream%20of%20almost%20everything.%0A%0A%20%20%20%20**Reading%20the%20printout%3A**%20total%20rows%2C%20the%20calendar%20span%2C%20an%20approximate%20total%0A%20%20%20%20playtime%20(summed%20track%20lengths)%2C%20unique%20artist%2Falbum%2Ftrack%20counts%2C%20and%20a%20sanity%0A%20%20%20%20line%20%E2%80%94%20number%20of%20sessions%2C%20median%20tracks%20per%20session%2C%20and%20the%20share%20of%0A%20%20%20%20one-track%20%22singleton%22%20sessions%20(high%20singletons%20would%20weaken%20co-occurrence).%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(SESSION_THRESHOLD%2C%20WES_ID%2C%20engine%2C%20pd%2C%20text)%3A%0A%20%20%20%20sessionized_plays_df%20%3D%20pd.read_sql(text(%22SELECT%20*%20FROM%20mart.fact_plays%20fp%20LEFT%20JOIN%20mart.dim_artist%20da%20ON%20da.artist_id%20%3D%20fp.primary_artist_id%22)%2C%20engine)%0A%20%20%20%20sessionized_plays_df%20%3D%20sessionized_plays_df%5Bsessionized_plays_df%5B%22owner_id%22%5D%20%3D%3D%20WES_ID%5D%20%23%20me%20only%20for%20now%0A%0A%20%20%20%20sessionized_plays_df%20%3D%20sessionized_plays_df.dropna(subset%3D%5B%22played_at%22%5D).copy()%0A%20%20%20%20sessionized_plays_df%5B%22played_at%22%5D%20%3D%20pd.to_datetime(sessionized_plays_df%5B%22played_at%22%5D%2C%20utc%3DTrue)%0A%20%20%20%20sessionized_plays_df%20%3D%20sessionized_plays_df.sort_values(%5B%22owner_id%22%2C%20%22played_at%22%5D).reset_index(drop%3DTrue)%0A%0A%20%20%20%20%23%20new%20session%20%3D%20gap%20from%20previous%20play%20(same%20owner)%20exceeds%20threshold%0A%20%20%20%20gap%20%3D%20sessionized_plays_df.groupby(%22owner_id%22)%5B%22played_at%22%5D.diff()%0A%20%20%20%20new_session%20%3D%20gap%20%3E%20SESSION_THRESHOLD%20%20%23%20first%20row%20per%20owner%20%E2%86%92%20NaT%20%3E%20thr%20%E2%86%92%20False%0A%0A%20%20%20%20%23%200-based%20session%20number%20within%20each%20owner%2C%20then%20a%20globally-unique%20id%0A%20%20%20%20sessionized_plays_df%5B%22session_seq%22%5D%20%3D%20new_session.groupby(sessionized_plays_df%5B%22owner_id%22%5D).cumsum()%0A%20%20%20%20sessionized_plays_df%5B%22session_id%22%5D%20%3D%20sessionized_plays_df%5B%22owner_id%22%5D%20%2B%20%22_%22%20%2B%20sessionized_plays_df%5B%22session_seq%22%5D.astype(str)%0A%0A%20%20%20%20print(f%22Loaded%20%7Blen(sessionized_plays_df)%3A%2C%7D%20rows%22)%0A%20%20%20%20print()%0A%20%20%20%20print(%0A%20%20%20%20%20%20%20%20f%22Time%20range%3A%20%7Bsessionized_plays_df%5B'played_at'%5D.min()%7D%20%E2%86%92%20%7Bsessionized_plays_df%5B'played_at'%5D.max()%7D%20%22%0A%20%20%20%20%20%20%20%20f%22(%7Bsessionized_plays_df%5B'played_at'%5D.max()%20-%20sessionized_plays_df%5B'played_at'%5D.min()%7D)%22%0A%20%20%20%20)%0A%20%20%20%20print(f%22Total%20playtime%20(approx%2C%20track-length%20basis)%3A%20%7Bsessionized_plays_df%5B'duration_ms'%5D.sum()%2F1000%2F60%2F60%3A%2C.0f%7D%20hours%22)%0A%20%20%20%20print(f%22Unique%20Artists%3A%20%7Bsessionized_plays_df%5B'primary_artist_id'%5D.nunique()%3A%2C%7D%22)%0A%20%20%20%20print(f%22Unique%20Albums%3A%20%20%7Bsessionized_plays_df%5B'album_id'%5D.nunique()%3A%2C%7D%22)%0A%20%20%20%20print(f%22Unique%20Tracks%3A%20%20%7Bsessionized_plays_df%5B'track_id'%5D.nunique()%3A%2C%7D%22)%0A%0A%20%20%20%20%23%20sanity%20check%20%E2%80%94%20expect%20~5K%20sessions%20for%20Wes%2C%20median%20~10%20tracks%2C%20few%20singletons%0A%20%20%20%20s%20%3D%20sessionized_plays_df.groupby(%5B%22owner_id%22%2C%20%22session_id%22%5D).size()%0A%20%20%20%20print()%0A%20%20%20%20print(%0A%20%20%20%20%20%20%20%20f%22%7Bs.size%3A%2C%7D%20sessions%20%7C%20median%20%7Bs.median()%3A.0f%7D%20tracks%2Fsession%20%7C%20%22%0A%20%20%20%20%20%20%20%20f%22%7B(s%20%3D%3D%201).mean()%3A.1%25%7D%20singletons%22%0A%20%20%20%20)%0A%0A%20%20%20%20sessionized_plays_df.sample(10)%0A%20%20%20%20return%20(sessionized_plays_df%2C)%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%20Create%20Month%20x%20Genre%20Matrix%0A%0A%20%20%20%20Goal%3A%20a%20matrix%20%24M%24%20with%20one%20**row%20per%20month**%2C%20one%20**column%20per%20genre**%2C%20where%0A%20%20%20%20each%20entry%20is%20that%20genre's%20*share*%20of%20the%20month's%20listening.%20This%20is%20the%20raw%0A%20%20%20%20material%20for%20the%20taste-drift%20analysis.%0A%0A%20%20%20%20**Fractional%20genre%20credit.**%20An%20artist%20can%20carry%20several%20genre%20tags%2C%20so%20each%0A%20%20%20%20play%20is%20split%20evenly%20across%20its%20artist's%20genres%3A%0A%20%20%20%20%24w%20%3D%201%20%2F%20(%5C%23%5Ctext%7Bgenres%20of%20the%20artist%7D)%24.%20That%20way%20every%20play%20contributes%20a%0A%20%20%20%20total%20weight%20of%201%2C%20no%20matter%20how%20many%20tags%20it%20has%20(%60SUM(w)%60%20per%20month%20%3D%20real%0A%20%20%20%20play%20count).%20The%20inner%20join%20silently%20drops%20the%20~1%E2%80%933%25%20of%20plays%20whose%20artist%20has%0A%20%20%20%20no%20genre%20tag.%0A%0A%20%20%20%20**Which%20genres%20to%20keep%20(%60build_m_time%60).**%20With%20hundreds%20of%20sparse%20tags%2C%20we%0A%20%20%20%20keep%20only%20genres%20that%20ever%20*mattered*%3A%20a%20genre%20survives%20if%20its%20share%20of%20**any%0A%20%20%20%20single%20confident%20month**%20reached%20%60peak_thresh%60.%20This%20deliberately%20favors%20genres%0A%20%20%20%20that%20were%20briefly%20dominant%20(the%20ones%20that%20*move*%20across%20the%20decade)%20over%0A%20%20%20%20perpetual%20low-level%20background%20noise%20%E2%80%94%20a%20better%20match%20for%20NMF%20archetypes%20that%0A%20%20%20%20switch%20on%20and%20off.%20Everything%20dropped%20is%20folded%20into%20a%20single%20%60other%60%20column%2C%0A%20%20%20%20and%20each%20row%20is%20renormalized%20to%20shares%20(sums%20to%20~1).%0A%0A%20%20%20%20-%20%60confident%60%20months%20%3D%20those%20with%20%60n_plays%20%E2%89%A5%20min_plays%60%3B%20the%20peak-share%20test%20is%0A%20%20%20%20%20%20only%20evaluated%20there%2C%20so%20one%20stray%20play%20in%20a%203-play%20month%20can't%20read%20as%2033%25%0A%20%20%20%20%20%20and%20smuggle%20in%20a%20junk%20genre.%0A%20%20%20%20-%20%60low_confidence%60%20flags%20sparse%20months%20(e.g.%20the%20Jul%E2%80%93Aug%202024%20tracking%20outage)%0A%20%20%20%20%20%20so%20we%20can%20*fit*%20on%20solid%20months%20but%20still%20*plot*%20every%20month.%0A%20%20%20%20-%20The%20series%20is%20clipped%20to%20a%20dense%20%22spine%22%20(from%20late%202018)%20to%20drop%20the%20ragged%0A%20%20%20%20%20%202016%E2%80%932018%20prologue.%0A%0A%20%20%20%20**Output%3A**%20%60M_time%60%20(months%20%C3%97%20genres%2C%20rows%20%3D%20shares)%20and%20%60meta%60%20(%60n_plays%60%20%2B%0A%20%20%20%20%60low_confidence%60%20per%20month).%20The%20printout%20reports%20how%20many%20genres%20survived%20and%0A%20%20%20%20the%20weighted%20coverage%20that%20*didn't*%20fall%20into%20%60other%60.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(WES_ID%2C%20engine%2C%20pd%2C%20text)%3A%0A%20%20%20%20%23%20---%20SQL%3A%20long%20format%20(month%2C%20genre%2C%20plays_weighted)%20-------------------------%0A%20%20%20%20%23%201.0%20%2F%20(%23genres%20for%20the%20play's%20artist)%20%3D%3E%20each%20play's%20weights%20sum%20to%201.0.%0A%20%20%20%20%23%20Inner%20JOIN%20drops%20the%20~1-3%25%20of%20plays%20whose%20artist%20has%20no%20genre%20tag.%0A%20%20%20%20M_TIME_QUERY%20%3D%20text(%0A%20%20%20%20%20%20%20%20%22%22%22%0A%20%20%20%20%20%20%20%20WITH%20plays%20AS%20(%0A%20%20%20%20%20%20%20%20%20%20%20%20SELECT%20play_id%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20primary_artist_id%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20date_trunc('month'%2C%20played_at)%3A%3Adate%20AS%20month%0A%20%20%20%20%20%20%20%20%20%20%20%20FROM%20mart.fact_plays%0A%20%20%20%20%20%20%20%20%20%20%20%20WHERE%20owner_id%20%3D%20%3Aowner_id%0A%20%20%20%20%20%20%20%20)%2C%0A%20%20%20%20%20%20%20%20play_genre%20AS%20(%0A%20%20%20%20%20%20%20%20%20%20%20%20SELECT%20p.month%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20bg.genre%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%201.0%20%2F%20count(*)%20OVER%20(PARTITION%20BY%20p.play_id)%20AS%20w%0A%20%20%20%20%20%20%20%20%20%20%20%20FROM%20plays%20p%0A%20%20%20%20%20%20%20%20%20%20%20%20JOIN%20mart.bridge_artist_genre%20bg%20ON%20bg.artist_id%20%3D%20p.primary_artist_id%0A%20%20%20%20%20%20%20%20)%0A%20%20%20%20%20%20%20%20SELECT%20month%2C%20genre%2C%20sum(w)%20AS%20plays_weighted%0A%20%20%20%20%20%20%20%20FROM%20play_genre%0A%20%20%20%20%20%20%20%20GROUP%20BY%20month%2C%20genre%0A%20%20%20%20%20%20%20%20ORDER%20BY%20month%2C%20genre%0A%20%20%20%20%20%20%20%20%22%22%22%0A%20%20%20%20)%0A%0A%0A%20%20%20%20def%20build_m_time(engine%2C%20peak_thresh%3D0.02%2C%20spine_start%3D%222018-12-01%22%2C%20min_plays%3D50)%3A%0A%20%20%20%20%20%20%20%20%22%22%22Return%20(M_time%2C%20meta).%0A%0A%20%20%20%20%20%20%20%20Genre%20selection%20uses%20a%20*peak%20monthly%20share*%20criterion%3A%20a%20genre%20is%20kept%20if%20it%0A%20%20%20%20%20%20%20%20ever%20made%20up%20%3E%3D%20%60peak_thresh%60%20of%20any%20single%20month's%20listening.%20This%20favors%0A%20%20%20%20%20%20%20%20genres%20that%20were%20briefly%20dominant%20(the%20ones%20that%20*move*%20over%20the%20decade)%20over%0A%20%20%20%20%20%20%20%20perpetual%20low-level%20background%20genres%20--%20better%20aligned%20with%20NMF%20archetypes%0A%20%20%20%20%20%20%20%20that%20turn%20on%20and%20off%20across%20the%20timeline.%20Everything%20else%20folds%20into%20%60other%60.%0A%0A%20%20%20%20%20%20%20%20M_time%20%3A%20DataFrame%2C%20month%20x%20genre%2C%20each%20row%20normalized%20to%20shares%20(sum%20~1.0).%0A%20%20%20%20%20%20%20%20meta%20%20%20%3A%20DataFrame%20indexed%20by%20month%20with%20raw%20%60n_plays%60%20and%20a%20%60low_confidence%60%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20flag%20for%20sparse%20months%20(e.g.%20the%20Jul-Aug%202024%20tracking%20outage).%0A%20%20%20%20%20%20%20%20%22%22%22%0A%20%20%20%20%20%20%20%20long%20%3D%20pd.read_sql(M_TIME_QUERY%2C%20engine%2C%20params%3D%7B%22owner_id%22%3A%20WES_ID%7D)%0A%20%20%20%20%20%20%20%20long%5B%22month%22%5D%20%3D%20pd.to_datetime(long%5B%22month%22%5D)%0A%0A%20%20%20%20%20%20%20%20%23%20pivot%20to%20full%20month%20x%20genre%20counts%20first%20--%20need%20per-month%20shares%20to%20pick%20genres%0A%20%20%20%20%20%20%20%20counts%20%3D%20long.pivot_table(%0A%20%20%20%20%20%20%20%20%20%20%20%20index%3D%22month%22%2C%20columns%3D%22genre%22%2C%20values%3D%22plays_weighted%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20aggfunc%3D%22sum%22%2C%20fill_value%3D0.0%2C%0A%20%20%20%20%20%20%20%20)%0A%0A%20%20%20%20%20%20%20%20%23%20per-month%20raw%20play%20count%20-%3E%20used%20both%20for%20selection%20masking%20and%20meta%0A%20%20%20%20%20%20%20%20n_plays%20%3D%20counts.sum(axis%3D1).round().astype(int).rename(%22n_plays%22)%0A%0A%20%20%20%20%20%20%20%20%23%20peak-share%20selection%2C%20evaluated%20only%20over%20*confident*%20months%20so%20a%20single%0A%20%20%20%20%20%20%20%20%23%20play%20in%20a%203-play%20month%20can't%20read%20as%2033%25%20share%20and%20sneak%20a%20junk%20genre%20in.%0A%20%20%20%20%20%20%20%20confident%20%3D%20n_plays%20%3E%3D%20min_plays%0A%20%20%20%20%20%20%20%20monthly_share%20%3D%20counts.div(counts.sum(axis%3D1)%2C%20axis%3D0)%0A%20%20%20%20%20%20%20%20keep%20%3D%20monthly_share.columns%5Bmonthly_share%5Bconfident%5D.max(axis%3D0)%20%3E%3D%20peak_thresh%5D%0A%0A%20%20%20%20%20%20%20%20%23%20fold%20the%20dropped%20genres%20into%20a%20single%20'other'%20column%2C%20then%20row-normalize%0A%20%20%20%20%20%20%20%20kept%20%3D%20counts%5Bkeep%5D%0A%20%20%20%20%20%20%20%20kept%5B%22other%22%5D%20%3D%20counts.drop(columns%3Dkeep).sum(axis%3D1)%0A%20%20%20%20%20%20%20%20m_time%20%3D%20kept.div(kept.sum(axis%3D1)%2C%20axis%3D0)%0A%0A%20%20%20%20%20%20%20%20coverage%20%3D%201.0%20-%20m_time%5B%22other%22%5D.mul(counts.sum(axis%3D1)).sum()%20%2F%20counts.values.sum()%0A%20%20%20%20%20%20%20%20print(f%22peak-share%20%3E%3D%20%7Bpeak_thresh%3A.0%25%7D%3A%20kept%20%7Blen(keep)%7D%20genres%2C%20%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20f%22%7Bcoverage%3A.1%25%7D%20weighted%20coverage%20(other%20%3D%20%7B1%20-%20coverage%3A.1%25%7D)%22)%0A%0A%20%20%20%20%20%20%20%20%23%20low-confidence%20flag%20for%20sparse%20months%20(n_plays%20computed%20above%20for%20selection)%0A%20%20%20%20%20%20%20%20meta%20%3D%20n_plays.to_frame()%0A%20%20%20%20%20%20%20%20meta%5B%22low_confidence%22%5D%20%3D%20meta%5B%22n_plays%22%5D%20%3C%20min_plays%0A%0A%20%20%20%20%20%20%20%20%23%20restrict%20to%20the%20dense%20spine%20(drops%202016%20prologue%20%2B%202017%2F2018%20gaps)%0A%20%20%20%20%20%20%20%20m_time%20%3D%20m_time.loc%5Bspine_start%3A%5D%0A%20%20%20%20%20%20%20%20meta%20%3D%20meta.loc%5Bspine_start%3A%5D%0A%0A%20%20%20%20%20%20%20%20print(f%22M_time%3A%20%7Bm_time.shape%5B0%5D%7D%20months%20x%20%7Bm_time.shape%5B1%5D%7D%20genres%20%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20f%22(%7Bmeta%5B'low_confidence'%5D.sum()%7D%20low-confidence%20months)%22)%0A%20%20%20%20%20%20%20%20return%20m_time%2C%20meta%0A%0A%20%20%20%20M_time%2C%20meta%20%3D%20build_m_time(engine%2C%20peak_thresh%3D0.0135)%0A%20%20%20%20return%20M_time%2C%20meta%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%20NMF%20Elbow%20Plot%0A%0A%20%20%20%20**Non-negative%20Matrix%20Factorization**%20approximates%20the%20month%20%C3%97%20genre%20matrix%20as%20a%0A%20%20%20%20product%20of%20two%20smaller%20non-negative%20matrices%3A%0A%0A%20%20%20%20%24%24%20M%20%5C%3B%5Capprox%5C%3B%20W%20H%2C%5Cqquad%0A%20%20%20%20%20%20%20%5Cunderbrace%7BW%7D_%7B%5Ctext%7Bmonths%7D%20%5Ctimes%20k%7D%5C%3B%5Cge%200%2C%5Cquad%0A%20%20%20%20%20%20%20%5Cunderbrace%7BH%7D_%7Bk%20%5Ctimes%20%5Ctext%7Bgenres%7D%7D%5C%3B%5Cge%200%20%24%24%0A%0A%20%20%20%20Each%20of%20the%20%24k%24%20rows%20of%20%24H%24%20is%20a%20**genre%20signature**%20(an%20%22archetype%22%20%E2%80%94%20a%20recipe%0A%20%20%20%20of%20genres)%2C%20and%20each%20row%20of%20%24W%24%20says%20how%20strongly%20a%20given%20month%20is%20a%20blend%20of%0A%20%20%20%20those%20archetypes.%20The%20non-negativity%20is%20the%20whole%20point%3A%20components%20can%20only%0A%20%20%20%20*add*%2C%20never%20cancel%2C%20so%20the%20parts%20are%20interpretable%20as%20%22this%20much%20metalcore%20%2B%0A%20%20%20%20this%20much%20cloud-rap%2C%22%20the%20way%20real%20listening%20actually%20mixes.%0A%0A%20%20%20%20NMF%20minimizes%20the%20reconstruction%20error%20%24%5ClVert%20M%20-%20WH%20%5CrVert_F%24%20(Frobenius%0A%20%20%20%20norm)%20for%20a%20chosen%20%24k%24.%20**How%20many%20archetypes%3F**%20Sweep%20%24k%24%20and%20plot%20the%20error%3A%0A%20%20%20%20it%20always%20decreases%2C%20but%20the%20marginal%20gain%20flattens%20once%20you've%20captured%20the%0A%20%20%20%20real%20structure.%20We%20pick%20%24k%24%20at%20that%20bend.%0A%0A%20%20%20%20**Reading%20the%20plot%3A**%20find%20the%20%22elbow%22%20where%20the%20curve%20stops%20dropping%20steeply%3B%0A%20%20%20%20the%20dashed%20red%20line%20marks%20the%20chosen%20**k%20%3D%206**.%20Fit%20is%20on%20confident%20months%20only%3B%0A%20%20%20%20scikit-learn%20convergence%20warnings%20are%20silenced%20because%20we%20run%20many%20short%20fits.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(NMF%2C%20fit_rows%2C%20plt)%3A%0A%20%20%20%20import%20warnings%0A%20%20%20%20from%20sklearn.exceptions%20import%20ConvergenceWarning%0A%0A%20%20%20%20%23%20Tell%20Python%20to%20ignore%20specifically%20the%20scikit-learn%20convergence%20warnings%0A%20%20%20%20warnings.filterwarnings(%22ignore%22%2C%20category%3DConvergenceWarning)%0A%0A%20%20%20%20errors%20%3D%20%5B%5D%0A%20%20%20%20k_values%20%3D%20range(1%2C%2015)%0A%0A%20%20%20%20for%20k_test%20in%20k_values%3A%0A%20%20%20%20%20%20%20%20%23%20fit_transform%20on%20your%20clean%20data%0A%20%20%20%20%20%20%20%20nmf_test%20%3D%20NMF(n_components%3Dk_test%2C%20init%3D%22nndsvda%22%2C%20max_iter%3D1000%2C%20random_state%3D0)%0A%20%20%20%20%20%20%20%20nmf_test.fit(fit_rows)%0A%20%20%20%20%20%20%20%20errors.append(nmf_test.reconstruction_err_)%0A%0A%20%20%20%20%23%20Plot%20the%20Elbow%0A%20%20%20%20plt.figure(figsize%3D(8%2C%204))%0A%20%20%20%20plt.plot(k_values%2C%20errors%2C%20marker%3D'o'%2C%20linestyle%3D'--')%0A%20%20%20%20plt.xlabel('Number%20of%20Archetypes%20(k)')%0A%20%20%20%20plt.ylabel('Reconstruction%20Error')%0A%20%20%20%20plt.title('Finding%20optimal%20k%20via%20the%20Elbow%20Method')%0A%20%20%20%20plt.xticks(k_values)%0A%20%20%20%20plt.grid(True%2C%20alpha%3D0.3)%0A%0A%20%20%20%20plt.axvline(x%3D6%2C%20color%3D'red'%2C%20linestyle%3D'%3A'%2C%20linewidth%3D2%2C%20label%3D'Chosen%20k%3D6')%0A%0A%20%20%20%20plt.show()%0A%20%20%20%20return%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%20NMF%20Archetypes%20%2B%20PCA%20Journey%0A%0A%20%20%20%20Two%20views%20of%20the%20same%20decade%20of%20taste.%0A%0A%20%20%20%20**1.%20NMF%20archetypes%20(the%20stacked-area%20plot).**%20Refit%20NMF%20with%20%24k%3D6%24%20on%20the%0A%20%20%20%20confident%20months%20to%20get%20%24W%24%20(month%20%E2%86%92%20archetype%20activations)%20and%20%24H%24%20(archetype%20%E2%86%92%0A%20%20%20%20genre%20signatures)%2C%20then%20*transform*%20**all**%20months%20(including%20low-confidence%0A%20%20%20%20ones)%20so%20the%20timeline%20has%20no%20holes.%20Printing%20the%20top%20genres%20of%20each%20%24H%24%20row%20lets%0A%20%20%20%20us%20hand-label%20the%20archetypes%20%E2%80%94%20here%3A%20cloud%20rap%2C%20metalcore%2C%20nu-metal%2Frap-metal%2C%0A%20%20%20%20post-grunge%2C%20alternative%20metal%2C%20rap%2Fhip-hop.%20The%20activations%20are%20renormalized%20to%0A%20%20%20%20shares%20and%203-month%20smoothed%2C%20then%20stacked%3A%20**the%20band%20widths%20show%20how%20my%0A%20%20%20%20listening%20mix%20sloshed%20between%20archetypes%20month%20by%20month.**%0A%0A%20%20%20%20**2.%20PCA%20drift%20trajectory%20(the%20scatter%20path).**%20Principal%20Component%20Analysis%0A%20%20%20%20finds%20orthogonal%20directions%20of%20maximum%20variance%20in%20the%20month%20%C3%97%20genre%20matrix%20and%0A%20%20%20%20projects%20each%20month%20onto%20the%20top%20two.%20Unlike%20NMF%2C%20PCA%20is%20*signed*%20and%20*linear*%2C%0A%20%20%20%20which%20makes%20it%20ideal%20for%20a%20**map%20of%20movement**%3A%0A%0A%20%20%20%20-%20each%20dot%20is%20one%20month%2C%20colored%20by%20time%20(viridis)%2C%20sized%20faint%20when%20sparse%3B%0A%20%20%20%20-%20the%20white%20path%20connects%20yearly%20centroids%20%E2%80%94%20the%20slow%20%22drift%22%20of%20taste%3B%0A%20%20%20%20-%20the%20axes%20are%20interpreted%20from%20their%20genre%20loadings%3A%20**PC1%20%E2%89%88%20metal%20%E2%86%94%20hip-hop**%2C%0A%20%20%20%20%20%20**PC2%20%E2%89%88%20old-school%20%E2%86%94%20modern%20metal**%2C%20with%20the%20%25%20of%20variance%20each%20explains.%0A%0A%20%20%20%20Read%20it%20as%20a%20journey%3A%20where%20the%20cloud%20sits%20in%20a%20given%20year%2C%20and%20how%20the%20yearly%0A%20%20%20%20centroids%20walk%20across%20genre-space%20over%20the%20decade.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(M_time%2C%20NMF%2C%20meta%2C%20np%2C%20pd%2C%20plt)%3A%0A%20%20%20%20from%20sklearn.decomposition%20import%20PCA%0A%0A%20%20%20%20k%20%3D%206%0A%0A%20%20%20%20fit_rows%20%3D%20M_time%5B~meta%5B%22low_confidence%22%5D%5D%20%20%20%20%20%20%23%20fit%20on%20confident%20months%20only...%0A%0A%20%20%20%20%23%20---%20NMF%3A%20archetypes%20%2B%20activation%20curves%20---%0A%20%20%20%20nmf%20%3D%20NMF(n_components%3Dk%2C%20init%3D%22nndsvda%22%2C%20max_iter%3D5000%2C%20random_state%3D42)%0A%20%20%20%20W%20%3D%20nmf.fit_transform(fit_rows)%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%23%20(months%20%C3%97%20k)%20activations%0A%20%20%20%20H%20%3D%20nmf.components_%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%23%20(k%20%C3%97%20genres)%20signatures%0A%20%20%20%20W_all%20%3D%20nmf.transform(M_time)%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%23%20...then%20project%20ALL%20months%20for%20the%20curves%0A%0A%20%20%20%20%23%20read%20each%20archetype%3A%20top%20genres%20in%20its%20signature%0A%20%20%20%20H_df%20%3D%20pd.DataFrame(H%2C%20columns%3DM_time.columns)%0A%20%20%20%20for%20i%20in%20range(k)%3A%0A%20%20%20%20%20%20%20%20print(f%22archetype%20%7Bi%7D%3A%22%2C%20list(H_df.iloc%5Bi%5D.nlargest(6).index))%0A%0A%20%20%20%20%23%20---%20PCA%3A%20drift%20trajectory%20---%0A%20%20%20%20pca%20%3D%20PCA(n_components%3D2%2C%20random_state%3D0)%0A%20%20%20%20coords%20%3D%20pca.fit_transform(M_time)%20%20%20%20%20%20%20%20%20%20%20%20%20%23%20each%20month%20%E2%86%92%20(PC1%2C%20PC2)%20for%20the%20path%20plot%0A%20%20%20%20print(%22explained%20variance%3A%22%2C%20pca.explained_variance_ratio_)%0A%0A%20%20%20%20names%20%3D%20%5B%22cloud%20rap%22%2C%20%22metalcore%22%2C%20%22nu-metal%20%2F%20rap-metal%22%2C%20%22post-grunge%22%2C%20%22alternative%20metal%22%2C%20%22rap%20%2F%20hip-hop%22%5D%0A%20%20%20%20W%20%3D%20pd.DataFrame(W_all%2C%20index%3DM_time.index%2C%20columns%3Dnames)%0A%20%20%20%20W%20%3D%20W.div(W.sum(axis%3D1)%2C%20axis%3D0).rolling(3%2C%20min_periods%3D1).mean()%20%20%20%23%20shares%2C%203-mo%20smooth%0A%0A%20%20%20%20fig%2C%20ax%20%3D%20plt.subplots(figsize%3D(14%2C%205))%0A%20%20%20%20ax.stackplot(W.index%2C%20W.T.values%2C%20labels%3DW.columns%2C%20alpha%3D0.85)%0A%20%20%20%20ax.set_ylim(0%2C%201)%3B%20ax.margins(x%3D0)%0A%20%20%20%20ax.legend(loc%3D%22upper%20left%22%2C%20ncol%3D4%2C%20fontsize%3D9)%0A%20%20%20%20ax.set_title(%22A%20decade%20of%20taste%20%E2%80%94%20NMF%20archetype%20share%20by%20month%22)%0A%20%20%20%20plt.show()%0A%0A%0A%20%20%20%20from%20matplotlib%20import%20cm%2C%20colors%0A%20%20%20%20import%20matplotlib.patheffects%20as%20pe%0A%0A%20%20%20%20C%20%3D%20pd.DataFrame(coords%2C%20index%3DM_time.index%2C%20columns%3D%5B%22PC1%22%2C%20%22PC2%22%5D).copy()%0A%20%20%20%20C%5B%22n_plays%22%5D%20%3D%20meta%5B%22n_plays%22%5D%0A%20%20%20%20C%5B%22t%22%5D%20%3D%20C.index.year%20%2B%20(C.index.month%20-%201)%20%2F%2012%0A%0A%20%20%20%20norm%20%3D%20colors.Normalize(C%5B%22t%22%5D.min()%2C%20C%5B%22t%22%5D.max())%0A%20%20%20%20cmap%20%3D%20cm.viridis%0A%0A%20%20%20%20rgba%20%3D%20cmap(norm(C%5B%22t%22%5D.values))%0A%20%20%20%20rgba%5B%3A%2C%203%5D%20%3D%20np.clip(C%5B%22n_plays%22%5D%20%2F%20200%2C%200.2%2C%201.0)%0A%0A%20%20%20%20fig%2C%20ax%20%3D%20plt.subplots(figsize%3D(14%2C%2012))%20%20%20%20%20%20%20%20%20%20%23%20scaled%20up%0A%20%20%20%20ax.plot(C.PC1%2C%20C.PC2%2C%20color%3D%22gray%22%2C%20lw%3D0.5%2C%20alpha%3D0.35%2C%20zorder%3D1)%0A%20%20%20%20ax.scatter(C.PC1%2C%20C.PC2%2C%20color%3Drgba%2C%20s%3D45%2C%20zorder%3D2)%0A%0A%20%20%20%20yr%20%3D%20C.groupby(C.index.year)%5B%5B%22PC1%22%2C%20%22PC2%22%5D%5D.mean()%0A%20%20%20%20ax.plot(yr.PC1%2C%20yr.PC2%2C%20color%3D%22white%22%2C%20lw%3D1.8%2C%20alpha%3D0.7%2C%20zorder%3D3)%0A%20%20%20%20ax.scatter(yr.PC1%2C%20yr.PC2%2C%20color%3Dcmap(norm(yr.index.values%20%2B%200.5))%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20s%3D620%2C%20edgecolor%3D%22white%22%2C%20linewidth%3D1.8%2C%20zorder%3D4)%20%20%20%20%20%20%20%23%20bigger%20markers%0A%20%20%20%20for%20y%2C%20(x%2C%20yy)%20in%20yr.iterrows()%3A%0A%20%20%20%20%20%20%20%20ax.annotate(str(y)%2C%20(x%2C%20yy)%2C%20color%3D%22white%22%2C%20fontsize%3D12%2C%20weight%3D%22bold%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20ha%3D%22center%22%2C%20va%3D%22center%22%2C%20zorder%3D5%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20path_effects%3D%5Bpe.withStroke(linewidth%3D2.5%2C%20foreground%3D%22black%22)%5D)%20%20%23%20readable%20on%20any%20fill%0A%0A%20%20%20%20sm%20%3D%20cm.ScalarMappable(norm%3Dnorm%2C%20cmap%3Dcmap)%3B%20sm.set_array(%5B%5D)%0A%20%20%20%20plt.colorbar(sm%2C%20ax%3Dax%2C%20label%3D%22year%22)%0A%20%20%20%20ax.set_xlabel(f%22PC1%20%20Metal%20%E2%86%92%20Hip-hop%20(%7Bpca.explained_variance_ratio_%5B0%5D%3A.0%25%7D)%22%2C%20fontsize%3D12)%0A%20%20%20%20ax.set_ylabel(f%22PC2%20%20Old-school%20%E2%86%92%20Modern%20Metal%20(%7Bpca.explained_variance_ratio_%5B1%5D%3A.0%25%7D)%22%2C%20fontsize%3D12)%0A%20%20%20%20ax.set_title(%22A%20decade%20of%20taste%2C%20drifting%20through%20genre-space%22%2C%20fontsize%3D15)%0A%20%20%20%20plt.tight_layout()%0A%20%20%20%20plt.show()%0A%20%20%20%20return%20W%2C%20fit_rows%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%20Logistic%20Regression%20%2F%20Random%20Forest%20Supervised%20Learning%0A%0A%20%20%20%20Now%20a%20supervised%20question%3A%20**given%20a%20month's%20archetype%20mix%2C%20can%20a%20model%20tell%0A%20%20%20%20*when*%20it%20happened%3F**%20If%20yes%2C%20taste%20genuinely%20drifted%20(the%20mix%20carries%20a%0A%20%20%20%20time-signature)%3B%20if%20it%20scored%20at%20chance%2C%20the%20archetypes%20would%20be%20timeless.%0A%0A%20%20%20%20-%20**Features**%20%24X%24%20%3D%20the%20smoothed%20NMF%20activations%20%24W%24%20(each%20month%20is%20a%20point%20in%0A%20%20%20%20%20%206-D%20archetype%20space).%0A%20%20%20%20-%20**Target**%20%24y%24%20%3D%20a%20time%20bucket%20of%20that%20month.%20Two%20difficulties%3A%0A%20%20%20%20%20%20*3%20eras*%20(pre-2020%20%2F%20pandemic%20%2F%20recent%20%E2%86%92%2033%25%20random%20baseline)%20and%0A%20%20%20%20%20%20*5%20biennial%20buckets*%20(16-17%20%E2%80%A6%2024%2B%20%E2%86%92%2020%25%20baseline).%0A%0A%20%20%20%20Two%20classifiers%20from%20ISLP%3A%0A%0A%20%20%20%20-%20**Random%20Forest**%20%E2%80%94%20a%20bag%20of%20decision%20trees%20on%20bootstrapped%20samples%3B%20its%0A%20%20%20%20%20%20%60feature_importances_%60%20literally%20rank%20*which%20archetype%20best%20dates%20a%20month*.%0A%20%20%20%20-%20**Multinomial%20Logistic%20Regression**%20%E2%80%94%20fits%20class%20log-odds%20by%20MLE%3B%20a%20linear%0A%20%20%20%20%20%20baseline%20for%20comparison.%0A%0A%20%20%20%20%60train_test_split(...%2C%20stratify%3Dy)%60%20keeps%20class%20proportions%20balanced%20across%20the%0A%20%20%20%20hold-out.%20The%20headline%20number%20is%20**5-fold%20cross-validation**%20accuracy%20(mean%20%C2%B1%0A%20%20%20%202%20sd)%20%E2%80%94%20proof%20the%20score%20wasn't%20a%20lucky%20split.%20Compare%20each%20accuracy%20against%20its%0A%20%20%20%20baseline%3A%20comfortably%20above%2033%25%2F20%25%20means%20the%20genre-mix%20really%20does%20encode%20time.%0A%0A%20%20%20%20*(The%20inner%20%60_(grouper)%60%20function%20is%20run%20twice%20%E2%80%94%20once%20per%20bucketing%20scheme%20%E2%80%94%20to%0A%20%20%20%20avoid%20duplicating%20the%20whole%20train%2Fevaluate%20block.)*%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(W)%3A%0A%20%20%20%20%23%20easy%20mode%2C%20baseline%2033%25%20accuracy%20just%20from%20random%20guessing%0A%20%20%20%20def%20get_era(year)%3A%0A%20%20%20%20%20%20%20%20if%20year%20%3C%3D%202019%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20return%20%22Early%20Era%20(Pre-2020)%22%0A%20%20%20%20%20%20%20%20elif%202020%20%3C%3D%20year%20%3C%3D%202021%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20return%20%22Pandemic%20Era%20(2020-2021)%22%0A%20%20%20%20%20%20%20%20else%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20return%20%22Recent%20Era%20(2022-Present)%22%0A%0A%20%20%20%20%23%20Grouping%20into%202%20year%20buckets%2C%20little%20more%20difficult%2020%25%20baseline%0A%20%20%20%20def%20get_biennial(year)%3A%0A%20%20%20%20%20%20%20%20if%20year%20%3C%3D%202017%3A%20return%20%2216-17%22%0A%20%20%20%20%20%20%20%20elif%20year%20%3C%3D%202019%3A%20return%20%2218-19%22%0A%20%20%20%20%20%20%20%20elif%20year%20%3C%3D%202021%3A%20return%20%2220-21%22%0A%20%20%20%20%20%20%20%20elif%20year%20%3C%3D%202023%3A%20return%20%2222-23%22%0A%20%20%20%20%20%20%20%20else%3A%20return%20%2224%2B%22%0A%0A%20%20%20%20def%20_(grouper)%3A%0A%20%20%20%20%20%20%20%20from%20sklearn.model_selection%20import%20train_test_split%2C%20cross_val_score%0A%20%20%20%20%20%20%20%20from%20sklearn.ensemble%20import%20RandomForestClassifier%0A%20%20%20%20%20%20%20%20from%20sklearn.linear_model%20import%20LogisticRegression%0A%20%20%20%20%20%20%20%20from%20sklearn.metrics%20import%20accuracy_score%2C%20classification_report%0A%0A%20%20%20%20%20%20%20%20%23%201.%20Define%20Features%20(X)%20and%20Target%20(y)%0A%20%20%20%20%20%20%20%20%23%20Let's%20use%20the%20'W'%20DataFrame%20you%20just%20built%20(the%20smoothed%20NMF%20archetypes)%0A%20%20%20%20%20%20%20%20X%20%3D%20W.copy()%20%20%23%20Shape%3A%20(~120%20months%20x%204%20archetypes)%0A%0A%20%20%20%20%20%20%20%20%23%20The%20target%20is%20the%20Year%20of%20that%20listening%20window%0A%20%20%20%20%20%20%20%20y%20%3D%20X.index.year.map(grouper).to_numpy()%0A%0A%20%20%20%20%20%20%20%20%23%20Note%3A%20If%20predicting%2010%20exact%20years%20is%20too%20noisy%2C%20you%20could%20group%20them%20%0A%20%20%20%20%20%20%20%20%23%20into%203%20categorical%20%22Eras%22%20instead%20(e.g.%2C%20%22College%22%2C%20%22Pandemic%22%2C%20%22Recent%22)%0A%0A%20%20%20%20%20%20%20%20%23%202.%20Train%2FTest%20Split%20(Holdout%20Set)%0A%20%20%20%20%20%20%20%20%23%20stratify%3Dy%20ensures%20we%20get%20a%20proportional%20mix%20of%20all%20years%20in%20both%20sets%0A%20%20%20%20%20%20%20%20X_train%2C%20X_test%2C%20y_train%2C%20y_test%20%3D%20train_test_split(%0A%20%20%20%20%20%20%20%20%20%20%20%20X%2C%20y%2C%20test_size%3D0.2%2C%20random_state%3D42%2C%20stratify%3Dy%0A%20%20%20%20%20%20%20%20)%0A%0A%20%20%20%20%20%20%20%20%23%20---------------------------------------------------------%0A%20%20%20%20%20%20%20%20%23%20MODEL%201%3A%20Random%20Forest%20(ISLP%20Chapter%208)%0A%20%20%20%20%20%20%20%20%23%20---------------------------------------------------------%0A%20%20%20%20%20%20%20%20rf_model%20%3D%20RandomForestClassifier(n_estimators%3D100%2C%20max_depth%3D5%2C%20random_state%3D42)%0A%20%20%20%20%20%20%20%20rf_model.fit(X_train%2C%20y_train)%0A%20%20%20%20%20%20%20%20rf_preds%20%3D%20rf_model.predict(X_test)%0A%0A%20%20%20%20%20%20%20%20print(%22---%20Random%20Forest%20---%22)%0A%20%20%20%20%20%20%20%20print(f%22Accuracy%3A%20%7Baccuracy_score(y_test%2C%20rf_preds)%3A.2%25%7D%22)%0A%20%20%20%20%20%20%20%20%23%20RF%20Superpower%3A%20It%20literally%20tells%20you%20which%20archetype%20is%20best%20at%20predicting%20time%0A%20%20%20%20%20%20%20%20print(%22Feature%20Importances%3A%22%2C%20dict(zip(X.columns%2C%20rf_model.feature_importances_)))%0A%0A%0A%20%20%20%20%20%20%20%20%23%20---------------------------------------------------------%0A%20%20%20%20%20%20%20%20%23%20MODEL%202%3A%20Multinomial%20Logistic%20Regression%20(ISLP%20Chapter%204)%0A%20%20%20%20%20%20%20%20%23%20---------------------------------------------------------%0A%20%20%20%20%20%20%20%20%23%20Because%20we%20are%20predicting%20%3E2%20years%2C%20it%20automatically%20uses%20'multinomial'%20MLE%0A%20%20%20%20%20%20%20%20log_reg%20%3D%20LogisticRegression(max_iter%3D1000%2C%20solver%3D'lbfgs')%0A%20%20%20%20%20%20%20%20log_reg.fit(X_train%2C%20y_train)%0A%20%20%20%20%20%20%20%20lr_preds%20%3D%20log_reg.predict(X_test)%0A%0A%20%20%20%20%20%20%20%20print(%22%5Cn---%20Logistic%20Regression%20---%22)%0A%20%20%20%20%20%20%20%20print(f%22Accuracy%3A%20%7Baccuracy_score(y_test%2C%20lr_preds)%3A.2%25%7D%22)%0A%0A%0A%20%20%20%20%20%20%20%20%23%20---------------------------------------------------------%0A%20%20%20%20%20%20%20%20%23%20THE%20PORTFOLIO%20HEADLINE%3A%205-Fold%20Cross%20Validation%0A%20%20%20%20%20%20%20%20%23%20---------------------------------------------------------%0A%20%20%20%20%20%20%20%20%23%20This%20proves%20to%20hiring%20managers%20your%20accuracy%20wasn't%20just%20a%20%22lucky%20split%22%0A%20%20%20%20%20%20%20%20cv_scores%20%3D%20cross_val_score(rf_model%2C%20X%2C%20y%2C%20cv%3D5)%0A%20%20%20%20%20%20%20%20print(%22%5Cn---%20Cross-Validation%20---%22)%0A%20%20%20%20%20%20%20%20return%20print(f%22True%20RF%20Accuracy%3A%20%7Bcv_scores.mean()%3A.2%25%7D%20(%2B%2F-%20%7Bcv_scores.std()%20*%202%3A.2%25%7D)%22)%0A%0A%20%20%20%20print('Random%20Forest%20Classifier%20%2B%20Logistic%20Regression')%0A%20%20%20%20print('--------------------------------------------------------------------------------')%0A%20%20%20%20print()%0A%0A%20%20%20%20print('Predicting%201%20of%203%20eras%20given%20a%20random%20listening%20session')%0A%20%20%20%20print('Baseline%20accuracy%3A%2033%25')%0A%20%20%20%20_(get_era)%0A%0A%20%20%20%20print('--------------------------------------------------------------------------------')%0A%20%20%20%20print()%0A%0A%20%20%20%20print('Predicting%201%20of%205%20biennial%20buckets%20given%20a%20random%20listening%20session')%0A%20%20%20%20print('Baseline%20accuracy%3A%2020%25')%0A%20%20%20%20_(get_biennial)%0A%20%20%20%20return%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%20What%20about%20a%20Random%20Forest%20Regressor%3F%0A%0A%20%20%20%20Buckets%20throw%20away%20information%20%E2%80%94%20a%20month%20is%20%2220-21%2C%22%20full%20stop.%20Here%20we%20instead%0A%20%20%20%20predict%20the%20**exact%20decimal%20year**%20as%20a%20continuous%20target%3A%0A%0A%20%20%20%20%24%24%20y%20%3D%20%5Ctext%7Byear%7D%20%2B%20%5Cfrac%7B%5Ctext%7Bmonth%7D-1%7D%7B12%7D%20%24%24%0A%0A%20%20%20%20so%20March%202018%20%E2%86%92%202018.17.%20A%20**Random%20Forest%20Regressor**%20averages%20many%20trees'%0A%20%20%20%20numeric%20predictions.%20(No%20%60stratify%60%20here%20%E2%80%94%20that's%20only%20for%20class%20labels%2C%20not%20a%0A%20%20%20%20continuous%20target.)%0A%0A%20%20%20%20**Metric%3A%20Mean%20Absolute%20Error**%2C%20in%20*years*.%20MAE%20is%20the%20typical%20miss%20in%20plain%0A%20%20%20%20units%3B%20multiply%20by%2012%20to%20read%20it%20as%20months.%20Cross-validated%20MAE%20(scikit-learn%0A%20%20%20%20scores%20it%20as%20*negative*%20MAE%2C%20so%20we%20flip%20the%20sign)%20is%20the%20honest%20figure.%0A%0A%20%20%20%20**Reading%20the%20plot%3A**%20each%20point%20is%20a%20held-out%20month%20%E2%80%94%20actual%20year%20on%20x%2C%0A%20%20%20%20predicted%20on%20y.%20The%20red%20dashed%20line%20is%20%24y%3Dx%24%20(perfect).%20Points%20hugging%20the%20line%0A%20%20%20%20%3D%20accurate%20dating%3B%20vertical%20scatter%20%3D%20the%20model's%20uncertainty%20for%20that%20era.%0A%20%20%20%20Tight%20years%20are%20eras%20with%20a%20distinctive%20genre%20fingerprint%3B%20fuzzy%20years%20are%20when%0A%20%20%20%20my%20taste%20looked%20like%20its%20neighbors.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(W%2C%20plt)%3A%0A%20%20%20%20from%20sklearn.model_selection%20import%20train_test_split%2C%20cross_val_score%0A%20%20%20%20from%20sklearn.ensemble%20import%20RandomForestRegressor%0A%20%20%20%20from%20sklearn.metrics%20import%20mean_absolute_error%0A%0A%20%20%20%20def%20run_regressor()%3A%0A%20%20%20%20%20%20%20%20X%20%3D%20W.copy()%0A%0A%20%20%20%20%20%20%20%20%23%20Create%20a%20continuous%20%22decimal%20year%22%20target%20%5Bcite%3A%2089%5D%0A%20%20%20%20%20%20%20%20%23%20Example%3A%20March%202018%20-%3E%202018%20%2B%20(3-1)%2F12%20%3D%202018.166%0A%20%20%20%20%20%20%20%20y%20%3D%20X.index.year%20%2B%20(X.index.month%20-%201)%20%2F%2012.0%0A%0A%20%20%20%20%20%20%20%20%23%20Train%2FTest%20Split%0A%20%20%20%20%20%20%20%20%23%20Note%3A%20CANNOT%20use%20stratify%3Dy%20here%20because%20y%20is%20continuous!%0A%20%20%20%20%20%20%20%20X_train%2C%20X_test%2C%20y_train%2C%20y_test%20%3D%20train_test_split(%0A%20%20%20%20%20%20%20%20%20%20%20%20X%2C%20y%2C%20test_size%3D0.2%2C%20random_state%3D42%0A%20%20%20%20%20%20%20%20)%0A%0A%20%20%20%20%20%20%20%20%23%20Fit%20Model%0A%20%20%20%20%20%20%20%20rf_reg%20%3D%20RandomForestRegressor(n_estimators%3D100%2C%20max_depth%3D5%2C%20random_state%3D42)%0A%20%20%20%20%20%20%20%20rf_reg.fit(X_train%2C%20y_train)%0A%20%20%20%20%20%20%20%20reg_preds%20%3D%20rf_reg.predict(X_test)%0A%0A%20%20%20%20%20%20%20%20%23%20Evaluation%0A%20%20%20%20%20%20%20%20mae%20%3D%20mean_absolute_error(y_test%2C%20reg_preds)%0A%0A%20%20%20%20%20%20%20%20print(%22---%20Random%20Forest%20Regressor%20---%22)%0A%20%20%20%20%20%20%20%20print(f%22Mean%20Absolute%20Error%20(Test%20Set)%3A%20%7Bmae%3A.2f%7D%20years%22)%0A%20%20%20%20%20%20%20%20print(f%22Margin%20of%20Error%3A%20~%7Bmae%20*%2012%3A.1f%7D%20months%22)%0A%20%20%20%20%20%20%20%20print(%22Feature%20Importances%3A%22%2C%20dict(zip(X.columns%2C%20rf_reg.feature_importances_)))%0A%0A%20%20%20%20%20%20%20%20%23%20Cross-Validation%0A%20%20%20%20%20%20%20%20%23%20Scikit-learn%20uses%20negative%20MAE%20for%20scoring%20(higher%20is%20%22better%22)%2C%20so%20flip%20it%0A%20%20%20%20%20%20%20%20cv_scores%20%3D%20cross_val_score(rf_reg%2C%20X%2C%20y%2C%20cv%3D5%2C%20scoring%3D'neg_mean_absolute_error')%0A%20%20%20%20%20%20%20%20cv_mae%20%3D%20-cv_scores.mean()%0A%20%20%20%20%20%20%20%20cv_std%20%3D%20cv_scores.std()%20*%202%0A%0A%20%20%20%20%20%20%20%20print(%22%5Cn---%20Cross-Validation%20---%22)%0A%20%20%20%20%20%20%20%20print(f%22True%20CV%20MAE%3A%20%7Bcv_mae%3A.2f%7D%20years%20(%2B%2F-%20%7Bcv_std%3A.2f%7D%20years)%22)%0A%0A%20%20%20%20%20%20%20%20%23%20Create%20the%20plot%0A%20%20%20%20%20%20%20%20plt.figure(figsize%3D(10%2C%206))%0A%20%20%20%20%20%20%20%20%23%20Scatter%20the%20actual%20test%20answers%20vs%20the%20model's%20guesses%0A%20%20%20%20%20%20%20%20plt.scatter(y_test%2C%20reg_preds%2C%20alpha%3D0.6%2C%20color%3D'dodgerblue'%2C%20edgecolor%3D'k'%2C%20label%3D'Random%20Month')%0A%0A%20%20%20%20%20%20%20%20%23%20Draw%20the%20perfect%20prediction%20diagonal%20line%20(y%20%3D%20x)%0A%20%20%20%20%20%20%20%20min_year%20%3D%20min(y_test.min()%2C%20reg_preds.min())%0A%20%20%20%20%20%20%20%20max_year%20%3D%20max(y_test.max()%2C%20reg_preds.max())%0A%20%20%20%20%20%20%20%20plt.plot(%5Bmin_year%2C%20max_year%5D%2C%20%5Bmin_year%2C%20max_year%5D%2C%20color%3D'red'%2C%20%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20linestyle%3D'--'%2C%20linewidth%3D2%2C%20label%3D'Perfect%20Prediction')%0A%0A%20%20%20%20%20%20%20%20plt.xlabel('Actual%20Year')%0A%20%20%20%20%20%20%20%20plt.ylabel('Predicted%20Year')%0A%20%20%20%20%20%20%20%20plt.title(f'Time%20Machine%3A%20Actual%20vs.%20Predicted%20Eras%5Cn(Mean%20Absolute%20Error%3A%20%7Bmae%3A.2f%7D%20years)')%0A%20%20%20%20%20%20%20%20plt.legend()%0A%20%20%20%20%20%20%20%20plt.grid(True%2C%20alpha%3D0.3)%0A%20%20%20%20%20%20%20%20plt.show()%0A%0A%20%20%20%20print('Predicting%20the%20exact%20decimal%20year%20given%20a%20random%20listening%20session')%0A%20%20%20%20run_regressor()%0A%20%20%20%20return%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%20Part%20Deux%20%E2%80%94%20An%20Artist%20Embedding%20from%20Co-Listening%0A%0A%20%20%20%20Act%20II%20drops%20genres%20entirely%20and%20lets%20**behavior**%20define%20the%20space%2C%20on%20one%0A%20%20%20%20assumption%20(the%20*distributional%20hypothesis*%2C%20straight%20out%20of%20NLP)%3A%20**artists%20I%0A%20%20%20%20play%20in%20the%20same%20session%20are%20related.**%20%22You%20shall%20know%20an%20artist%20by%20the%20company%0A%20%20%20%20it%20keeps.%22%0A%0A%20%20%20%20**Step%201%20%E2%80%94%20the%20co-occurrence%20matrix.**%20For%20every%20session%2C%20take%20the%20*set*%20of%0A%20%20%20%20artists%20in%20it%20and%20count%20each%20co-occurring%20pair.%20Accumulate%20into%20a%20symmetric%0A%20%20%20%20**artist%20%C3%97%20artist**%20matrix%20%24M_%7Bco%7D%24%2C%20where%20%24M_%7Bco%7D%5Bi%2Cj%5D%24%20%3D%20number%20of%20sessions%20in%0A%20%20%20%20which%20%24i%24%20and%20%24j%24%20appeared%20together.%20Using%20a%20%60set()%60%20per%20session%20means%20playing%0A%20%20%20%20one%20artist%20twice%20in%20a%20sitting%20doesn't%20inflate%20anything.%0A%0A%20%20%20%20**Why%20sparse.**%20~2%2C702%20unique%20artists%20%E2%87%92%20%242702%5E2%20%5Capprox%24%20**7.3%20million**%20cells%2C%0A%20%20%20%20~97%25%20of%20them%20zero%20(most%20artist%20pairs%20never%20share%20a%20session).%20A%20dense%20array%20would%0A%20%20%20%20waste%20memory%3B%20%60scipy.sparse.csr_matrix%60%20stores%20only%20the%20non-zeros.%20The%20printout%0A%20%20%20%20reports%20the%20non-zero%20count%20and%20the%20matrix%20**density**%20(fraction%20filled).%0A%0A%20%20%20%20%60combinations(sorted(set(...))%2C%202)%60%20from%20%60itertools%60%20is%20the%20fast%20C%20path%20for%0A%20%20%20%20%22every%20unordered%20pair%2C%22%20and%20sorting%20first%20ensures%20%24(A%2CB)%24%20and%20%24(B%2CA)%24%20aren't%0A%20%20%20%20double-counted%20before%20we%20symmetrize.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(sessionized_plays_df)%3A%0A%20%20%20%20import%20scipy.sparse%20as%20sp%0A%20%20%20%20from%20itertools%20import%20combinations%0A%20%20%20%20from%20collections%20import%20Counter%0A%0A%20%20%20%20%23%201.%20Group%20by%20your%20custom%20session_id%20and%20get%20unique%20artist%20IDs%20per%20session%0A%20%20%20%20%23%20(Using%20set()%20ensures%20an%20artist%20doesn't%20co-occur%20with%20themselves%20if%20you%20played%20them%20twice%20in%20a%20row)%0A%20%20%20%20print(%22Grouping%20sessions...%22)%0A%20%20%20%20sessions%20%3D%20sessionized_plays_df.groupby('session_id')%5B'primary_artist_id'%5D.apply(lambda%20x%3A%20list(set(x)))%0A%0A%20%20%20%20%23%202.%20Count%20all%20pairs%20incredibly%20fast%20using%20C-optimized%20itertools%0A%20%20%20%20print(%22Counting%20co-occurrences...%22)%0A%20%20%20%20pair_counts%20%3D%20Counter()%0A%0A%20%20%20%20for%20session_artists%20in%20sessions%3A%0A%20%20%20%20%20%20%20%20if%20len(session_artists)%20%3E%201%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20%23%20Sorting%20ensures%20that%20(Artist%20A%2C%20Artist%20B)%20and%20(Artist%20B%2C%20Artist%20A)%20%0A%20%20%20%20%20%20%20%20%20%20%20%20%23%20aren't%20counted%20as%20two%20separate%20distinct%20pairs%0A%20%20%20%20%20%20%20%20%20%20%20%20session_artists.sort()%0A%20%20%20%20%20%20%20%20%20%20%20%20pairs%20%3D%20combinations(session_artists%2C%202)%0A%20%20%20%20%20%20%20%20%20%20%20%20pair_counts.update(pairs)%0A%0A%20%20%20%20%23%203.%20Create%20a%20mapping%20of%20Spotify%20Artist%20ID%20-%3E%20Integer%20Index%20%0A%20%20%20%20%23%20(Matrices%20only%20understand%20row%2Fcol%20numbers%2C%20not%20string%20IDs)%0A%20%20%20%20print(%22Mapping%20dimensions...%22)%0A%20%20%20%20unique_artists%20%3D%20sorted(list(set(sessionized_plays_df%5B'primary_artist_id'%5D)))%0A%20%20%20%20artist_to_idx%20%3D%20%7Bartist_id%3A%20i%20for%20i%2C%20artist_id%20in%20enumerate(unique_artists)%7D%0A%20%20%20%20n_artists%20%3D%20len(unique_artists)%0A%0A%20%20%20%20%23%204.%20Extract%20the%20row%2C%20column%2C%20and%20data%20(count)%20arrays%20for%20the%20sparse%20matrix%0A%20%20%20%20rows%20%3D%20%5B%5D%0A%20%20%20%20cols%20%3D%20%5B%5D%0A%20%20%20%20data%20%3D%20%5B%5D%0A%0A%20%20%20%20for%20(artist1%2C%20artist2)%2C%20count%20in%20pair_counts.items()%3A%0A%20%20%20%20%20%20%20%20idx1%20%3D%20artist_to_idx%5Bartist1%5D%0A%20%20%20%20%20%20%20%20idx2%20%3D%20artist_to_idx%5Bartist2%5D%0A%0A%20%20%20%20%20%20%20%20%23%20Since%20the%20matrix%20is%20symmetric%2C%20an%20intersection%20goes%20both%20ways%0A%20%20%20%20%20%20%20%20rows.extend(%5Bidx1%2C%20idx2%5D)%0A%20%20%20%20%20%20%20%20cols.extend(%5Bidx2%2C%20idx1%5D)%0A%20%20%20%20%20%20%20%20data.extend(%5Bcount%2C%20count%5D)%0A%0A%20%20%20%20%23%205.%20Build%20the%20Scipy%20Sparse%20Matrix!%0A%20%20%20%20print(%22Building%20sparse%20matrix...%22)%0A%20%20%20%20M_co%20%3D%20sp.csr_matrix((data%2C%20(rows%2C%20cols))%2C%20shape%3D(n_artists%2C%20n_artists))%0A%0A%20%20%20%20print(%22-%22%20*%2030)%0A%20%20%20%20print(f%22Matrix%20built%3A%20%7BM_co.shape%5B0%5D%3A%2C%7D%20artists%20x%20%7BM_co.shape%5B1%5D%3A%2C%7D%20artists%22)%0A%20%20%20%20print(f%22Number%20of%20non-zero%20pairs%3A%20%7BM_co.nnz%3A%2C%7D%22)%0A%20%20%20%20%23%20Matrix%20Density%3A%20How%20full%20the%20matrix%20is%20compared%20to%20a%20totally%20dense%20one%0A%20%20%20%20density%20%3D%20M_co.nnz%20%2F%20(n_artists%20*%20n_artists)%0A%20%20%20%20print(f%22Matrix%20density%3A%20%7Bdensity%3A.4%25%7D%22)%0A%20%20%20%20return%20M_co%2C%20artist_to_idx%2C%20n_artists%2C%20sp%2C%20unique_artists%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%23%20This%20is%20almost%20ready%20but%20there%20is%20a%20problem%20here%0A%0A%20%20%20%20I%20listen%20to%20a%20lot%20of%20BONES%20(top%200.1%25%20listener%20btw)%20and%20so%20there%20will%20be%20many%20intersections%20in%20this%20matrix%20with%20the%20BONES%20row%20that%20are%20elevated%20simply%20because%20I%20listen%20to%20BONES%20very%20often.%20Not%20because%20they%20are%20similar%20in%20musical%20vibe%2Fmood.%0A%0A%20%20%20%20The%20fix%20is%20**Shifted-PPMI%20(Positive%20Pointwise%20Mutual%20Information)**%0A%0A%20%20%20%20PPMI%20doesn't%20ask%20%22How%20many%20times%20did%20Artist%20A%20and%20B%20play%20together%3F%22%20It%20asks%3A%20%22Did%20Artist%20A%20and%20B%20play%20together%20more%20often%20than%20random%20chance%20would%20predict%3F%22%0A%0A%20%20%20%201.%20**Probability%3A**%20If%20Artist%20A%20makes%20up%2010%25%20of%20my%20total%20listening%2C%20and%20Artist%20B%20makes%20up%205%25%2C%20the%20chance%20of%20them%20randomly%20occurring%20in%20the%20same%20session%20is%20%240.10%20%5Ctimes%200.05%20%3D%200.005%24%20(0.5%25).%0A%20%20%20%202.%20**Mutual%20Information%3A**%20Take%20the%20actual%20co-occurrences%20and%20divide%20by%20that%20expected%20random%20chance.%20If%20the%20actual%20overlaps%20are%20higher%20than%20expected%2C%20the%20score%20goes%20up.%20If%20it's%20just%20happening%20because%20Artist%20A%20is%20popular%2C%20the%20score%20gets%20crushed.%0A%20%20%20%203.%20**Positive%3A**%20The%20formula%20uses%20a%20logarithm.%20If%20artists%20co-occur%20less%20than%20random%20chance%2C%20the%20log%20goes%20negative.%20All%20negative%20numbers%20are%20just%20snapped%20to%200%20(hence%20the%20Positive%20in%20PPMI).%0A%0A%20%20%20%20Formally%2C%20with%20%24P(i%2Cj)%24%20the%20observed%20co-occurrence%20probability%20and%20%24P(i)%2CP(j)%24%0A%20%20%20%20the%20marginals%3A%0A%0A%20%20%20%20%24%24%20%5Cmathrm%7BPMI%7D(i%2Cj)%20%3D%20%5Clog_2%20%5Cfrac%7BP(i%2Cj)%7D%7BP(i)%5C%2CP(j)%7D%0A%20%20%20%20%20%20%20%3D%20%5Clog_2%20%5Cfrac%7BO_%7Bij%7D%5C%2CT%7D%7B%5Ctext%7Btot%7D_i%5C%2C%5Ctext%7Btot%7D_j%7D%2C%5Cqquad%0A%20%20%20%20%20%20%20%5Cmathrm%7BPPMI%7D%20%3D%20%5Cmax(%5Cmathrm%7BPMI%7D%20-%20%5Clog_2%20k%2C%5C%3B%200)%20%24%24%0A%0A%20%20%20%20Computed%20only%20on%20the%20non-zero%20cells%20(everywhere%20else%20is%20already%200).%20The%20shift%0A%20%20%20%20%24k%24%20(Levy%20%26%20Goldberg)%20subtracts%20a%20constant%20before%20clipping%3B%20%24k%3D1%24%20is%20plain%20PPMI%0A%20%20%20%20and%20mimics%20word2vec's%20negative%20sampling.%20**Keep%20this%20clip%20in%20mind%20%E2%80%94%20it's%20exactly%0A%20%20%20%20why%20%22exact%20opposites%22%20later%20collapse%20into%20empty%20space%3A**%20all%20negative%0A%20%20%20%20association%20is%20thrown%20away%20here.%0A%0A%20%20%20%20%23%23%23%20Then%3A%20compress%20with%20Truncated%20SVD%20%E2%86%92%2050-D%20artist%20vectors%0A%0A%20%20%20%20The%20PPMI%20matrix%20is%20still%202%2C702-D%20and%20sparse.%20**Truncated%20SVD**%20factors%20it%20as%0A%20%20%20%20%24M_%7Bppmi%7D%20%5Capprox%20U%20%5CSigma%20V%5E%5Ctop%24%20and%20keeps%20the%20top%2050%20singular%20directions%2C%0A%20%20%20%20giving%20each%20artist%20a%20dense%20**50-D%20vector**%20(%24U%5CSigma%24).%20Levy%20%26%20Goldberg's%20result%0A%20%20%20%20is%20that%20*SVD-of-PPMI%20approximates%20word2vec*%2C%20with%20%24u_i%20%5Ccdot%20u_j%20%5Capprox%0A%20%20%20%20%5Cmathrm%7BPPMI%7D(i%2Cj)%24%20%E2%80%94%20so%20dot%20products%20recover%20association%20strength%2C%20and%20the%2050%0A%20%20%20%20dimensions%20are%20latent%20%22taste%20axes.%22%20%60explained_variance_ratio_.sum()%60%20reports%20how%0A%20%20%20%20much%20structure%20those%2050%20dims%20retain.%20Everything%20in%20Act%20II%20%E2%80%94%20similarity%2C%0A%20%20%20%20analogies%2C%20opposites%2C%20the%20brain%20map%20%E2%80%94%20is%20geometry%20on%20these%20vectors.%20The%20cell%20ends%0A%20%20%20%20with%20a%20%60get_similar_artists()%60%20cosine-nearest-neighbor%20sanity%20check.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(%0A%20%20%20%20M_co%2C%0A%20%20%20%20artist_to_idx%2C%0A%20%20%20%20n_artists%2C%0A%20%20%20%20np%2C%0A%20%20%20%20sessionized_plays_df%2C%0A%20%20%20%20sp%2C%0A%20%20%20%20unique_artists%2C%0A)%3A%0A%20%20%20%20def%20_()%3A%0A%20%20%20%20%20%20%20%20print(%22Calculating%20probabilities...%22)%0A%20%20%20%20%20%20%20%20%23%201.%20Sum%20across%20the%20rows%20to%20get%20the%20total%20frequency%20of%20each%20artist%0A%20%20%20%20%20%20%20%20%23%20(We%20flatten%20it%20into%20a%201D%20numpy%20array%20so%20we%20can%20do%20fast%20math%20on%20it)%0A%20%20%20%20%20%20%20%20artist_totals%20%3D%20np.array(M_co.sum(axis%3D1)).flatten()%0A%20%20%20%20%20%20%20%20total_pairs%20%3D%20M_co.sum()%0A%0A%20%20%20%20%20%20%20%20%23%202.%20Extract%20the%20non-zero%20elements%20from%20our%20sparse%20matrix%0A%20%20%20%20%20%20%20%20rows%2C%20cols%20%3D%20M_co.nonzero()%0A%20%20%20%20%20%20%20%20observed_counts%20%3D%20M_co.data%0A%0A%20%20%20%20%20%20%20%20print(%22Calculating%20Expected%20random%20chance...%22)%0A%20%20%20%20%20%20%20%20%23%20Expected%20count%20%3D%20(Total%20A%20*%20Total%20B)%20%2F%20Total%20overall%0A%20%20%20%20%20%20%20%20%23%20We%20only%20calculate%20this%20for%20the%20non-zero%20cells%20to%20save%20time!%0A%20%20%20%20%20%20%20%20expected_counts%20%3D%20(artist_totals%5Brows%5D%20*%20artist_totals%5Bcols%5D)%20%2F%20total_pairs%0A%0A%20%20%20%20%20%20%20%20print(%22Calculating%20PPMI...%22)%0A%20%20%20%20%20%20%20%20%23%20PMI%20%3D%20log(Observed%20%2F%20Expected)%0A%20%20%20%20%20%20%20%20%23%20Shifted-PPMI%20introduces%20a%20shift%20parameter%20(k)%20to%20penalize%20rare%20events.%0A%20%20%20%20%20%20%20%20%23%20Levy%20%26%20Goldberg%20proved%20that%20a%20shift%20of%20k%3D1%20or%20k%3D5%20perfectly%20mimics%20Word2Vec.%0A%20%20%20%20%20%20%20%20shift_k%20%3D%201%20%20%0A%20%20%20%20%20%20%20%20pmi_values%20%3D%20np.log2(observed_counts%20%2F%20expected_counts)%20-%20np.log2(shift_k)%0A%0A%20%20%20%20%20%20%20%20%23%20The%20%22Positive%22%20in%20PPMI%3A%20snap%20all%20negative%20values%20to%200%0A%20%20%20%20%20%20%20%20ppmi_values%20%3D%20np.clip(pmi_values%2C%20a_min%3D0%2C%20a_max%3DNone)%0A%0A%20%20%20%20%20%20%20%20%23%203.%20Build%20the%20final%2C%20mathematically%20clean%20Sparse%20Matrix%0A%20%20%20%20%20%20%20%20M_ppmi%20%3D%20sp.csr_matrix((ppmi_values%2C%20(rows%2C%20cols))%2C%20shape%3D(n_artists%2C%20n_artists))%0A%0A%20%20%20%20%20%20%20%20%23%20Let's%20clean%20up%20explicitly%20to%20free%20RAM%0A%20%20%20%20%20%20%20%20M_ppmi.eliminate_zeros()%0A%0A%20%20%20%20%20%20%20%20print(%22-%22%20*%2030)%0A%20%20%20%20%20%20%20%20print(f%22Shifted-PPMI%20Matrix%20built%3A%20%7BM_ppmi.shape%7D%22)%0A%20%20%20%20%20%20%20%20print(f%22Original%20Non-zeros%3A%20%7BM_co.nnz%3A%2C%7D%22)%0A%20%20%20%20%20%20%20%20print(f%22PPMI%20Non-zeros%3A%20%7BM_ppmi.nnz%3A%2C%7D%22)%0A%20%20%20%20%20%20%20%20return%20M_ppmi%0A%0A%0A%20%20%20%20from%20sklearn.decomposition%20import%20TruncatedSVD%0A%20%20%20%20from%20sklearn.metrics.pairwise%20import%20cosine_similarity%0A%0A%20%20%20%20%23%201.%20The%20Strang%20Magic%3A%20Truncated%20SVD%0A%20%20%20%20%23%20We%20are%20compressing%202%2C702%20dimensions%20down%20to%2050%20dense%20dimensions%0A%20%20%20%20print(%22Running%20Truncated%20SVD...%22)%0A%20%20%20%20embedding_size%20%3D%2050%0A%20%20%20%20svd%20%3D%20TruncatedSVD(n_components%3Dembedding_size%2C%20random_state%3D42)%0A%0A%20%20%20%20%23%20Fit%20the%20model%20and%20extract%20the%20U%20*%20Sigma%20matrices%0A%20%20%20%20%23%20This%20turns%20our%20(2702%2C%202702)%20matrix%20into%20a%20(2702%2C%2050)%20matrix!%0A%20%20%20%20artist_vectors%20%3D%20svd.fit_transform(_())%0A%0A%20%20%20%20print(f%22Explained%20Variance%20by%2050%20components%3A%20%7Bsvd.explained_variance_ratio_.sum()%3A.2%25%7D%22)%0A%20%20%20%20print(f%22Artist%20Vectors%20shape%3A%20%7Bartist_vectors.shape%7D%22)%0A%0A%20%20%20%20%23%202.%20Map%20the%20math%20back%20to%20human%20names%0A%20%20%20%20%23%20Let's%20create%20a%20dictionary%20to%20easily%20look%20up%20an%20artist's%20vector%20by%20their%20Spotify%20ID%0A%20%20%20%20vector_dict%20%3D%20%7Bunique_artists%5Bi%5D%3A%20artist_vectors%5Bi%5D%20for%20i%20in%20range(n_artists)%7D%0A%0A%20%20%20%20%23%20Create%20a%20helper%20function%20to%20get%20an%20artist's%20text%20name%20from%20their%20ID%20%0A%20%20%20%20%23%20(Assuming%20your%20original%20df%20has%20'primary_artist_id'%20and%20'artist_name')%0A%20%20%20%20id_to_name%20%3D%20sessionized_plays_df.drop_duplicates('primary_artist_id').set_index('primary_artist_id')%5B'name'%5D.to_dict()%0A%0A%20%20%20%20%23%203.%20The%20Ultimate%20Test%3A%20Cosine%20Similarity%0A%20%20%20%20def%20get_similar_artists(artist_name%2C%20top_n%3D5)%3A%0A%20%20%20%20%20%20%20%20%23%20Find%20the%20ID%20for%20the%20given%20name%20(taking%20the%20first%20match)%0A%20%20%20%20%20%20%20%20try%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20target_id%20%3D%20next(k%20for%20k%2C%20v%20in%20id_to_name.items()%20if%20v.lower()%20%3D%3D%20artist_name.lower())%0A%20%20%20%20%20%20%20%20except%20StopIteration%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20return%20f%22Could%20not%20find%20%7Bartist_name%7D%20in%20your%20listening%20history.%22%0A%0A%20%20%20%20%20%20%20%20target_idx%20%3D%20artist_to_idx%5Btarget_id%5D%0A%20%20%20%20%20%20%20%20target_vector%20%3D%20artist_vectors%5Btarget_idx%5D.reshape(1%2C%20-1)%0A%0A%20%20%20%20%20%20%20%20%23%20Calculate%20the%20angle%20(cosine%20similarity)%20between%20this%20artist%20and%20ALL%20other%20artists%0A%20%20%20%20%20%20%20%20similarities%20%3D%20cosine_similarity(target_vector%2C%20artist_vectors)%5B0%5D%0A%0A%20%20%20%20%20%20%20%20%23%20Get%20the%20indices%20of%20the%20top%20N%20highest%20scores%20(excluding%20the%20artist%20themselves)%0A%20%20%20%20%20%20%20%20similar_indices%20%3D%20similarities.argsort()%5B-(top_n%2B1)%3A-1%5D%5B%3A%3A-1%5D%0A%0A%20%20%20%20%20%20%20%20print(f%22---%20Artists%20most%20similar%20to%3A%20%7Bartist_name.title()%7D%20---%22)%0A%20%20%20%20%20%20%20%20for%20idx%20in%20similar_indices%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20match_id%20%3D%20unique_artists%5Bidx%5D%0A%20%20%20%20%20%20%20%20%20%20%20%20match_name%20%3D%20id_to_name%5Bmatch_id%5D%0A%20%20%20%20%20%20%20%20%20%20%20%20score%20%3D%20similarities%5Bidx%5D%0A%20%20%20%20%20%20%20%20%20%20%20%20print(f%22%7Bmatch_name%7D%3A%20%7Bscore%3A.3f%7D%22)%0A%0A%20%20%20%20%23%20TEST%20IT%20OUT!%0A%20%20%20%20%23%20Pick%20an%20artist%20you%20listen%20to%20a%20lot%20and%20run%20the%20function%3A%0A%20%20%20%20get_similar_artists(%22BONES%22)%20%23%20Replace%20with%20one%20of%20your%20top%20artists!%0A%20%20%20%20return%20artist_vectors%2C%20cosine_similarity%2C%20id_to_name%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%20The%20Musical%20Brain%20(2D)%0A%0A%20%20%20%20First%20look%20at%20the%20embedding%3A%20**t-SNE**%20the%2050-D%20artist%20vectors%20down%20to%202-D.%0A%20%20%20%20t-SNE%20is%20nonlinear%20%E2%80%94%20it%20preserves%20*local*%20neighborhoods%2C%20so%20artists%20I%20co-listen%0A%20%20%20%20cluster%20together%2C%20but%20the%20axes%20carry%20no%20meaning%20(ticks%20hidden).%20Top-500%20by%20plays%2C%0A%20%20%20%20dots%20sized%20by%20play%20count%2C%20hover%20for%20names.%20This%20is%20the%20flat%20ancestor%20of%20the%203-D%0A%20%20%20%20brain%20at%20the%20bottom%20of%20the%20notebook%3B%20the%20sections%20in%20between%20make%20the%20geometry%0A%20%20%20%20*do*%20something%20(similarity%2C%20analogies%2C%20opposites).%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(%0A%20%20%20%20artist_vectors%2C%0A%20%20%20%20id_to_name%2C%0A%20%20%20%20n_artists%2C%0A%20%20%20%20pd%2C%0A%20%20%20%20sessionized_plays_df%2C%0A%20%20%20%20unique_artists%2C%0A)%3A%0A%20%20%20%20def%20_()%3A%0A%20%20%20%20%20%20%20%20from%20sklearn.manifold%20import%20TSNE%0A%20%20%20%20%20%20%20%20import%20plotly.express%20as%20px%0A%0A%20%20%20%20%20%20%20%20%23%201.%20Manifold%20Learning%3A%20Squish%20from%2050D%20down%20to%202D%0A%20%20%20%20%20%20%20%20print(%22Running%20t-SNE%20(this%20usually%20takes%2010-30%20seconds)...%22)%0A%20%20%20%20%20%20%20%20%23%20Notice%20we%20run%20it%20on%20your%20dense%2050D%20artist_vectors%2C%20NOT%20the%202700D%20sparse%20matrix!%0A%20%20%20%20%20%20%20%20tsne%20%3D%20TSNE(n_components%3D2%2C%20perplexity%3D30%2C%20random_state%3D42)%0A%20%20%20%20%20%20%20%20vectors_2d%20%3D%20tsne.fit_transform(artist_vectors)%0A%0A%20%20%20%20%20%20%20%20%23%202.%20Build%20a%20DataFrame%20for%20the%20visualizer%0A%20%20%20%20%20%20%20%20viz_df%20%3D%20pd.DataFrame(vectors_2d%2C%20columns%3D%5B'X'%2C%20'Y'%5D)%0A%20%20%20%20%20%20%20%20%23%20Re-attach%20the%20human%20names%0A%20%20%20%20%20%20%20%20viz_df%5B'Artist'%5D%20%3D%20%5Bid_to_name%5Bunique_artists%5Bi%5D%5D%20for%20i%20in%20range(n_artists)%5D%0A%0A%20%20%20%20%20%20%20%20%23%203.%20Size%20the%20dots%20by%20popularity!%20%0A%20%20%20%20%20%20%20%20%23%20Let's%20count%20how%20many%20times%20you%20played%20each%20artist%20so%20bigger%20dots%20%3D%20favorite%20artists%0A%20%20%20%20%20%20%20%20artist_counts%20%3D%20sessionized_plays_df%5B'primary_artist_id'%5D.value_counts()%0A%20%20%20%20%20%20%20%20viz_df%5B'Plays'%5D%20%3D%20%5Bartist_counts.get(unique_artists%5Bi%5D%2C%201)%20for%20i%20in%20range(n_artists)%5D%0A%0A%20%20%20%20%20%20%20%20%23%20Filter%20out%20the%20'one-hit%20wonders'%20so%20the%20graph%20isn't%20too%20cluttered%0A%20%20%20%20%20%20%20%20%23%20Let's%20just%20plot%20the%20top%20500%20artists%20you%20listen%20to%20most%0A%20%20%20%20%20%20%20%20top_viz_df%20%3D%20viz_df.nlargest(500%2C%20'Plays')%0A%0A%20%20%20%20%20%20%20%20%23%204.%20Draw%20the%20Interactive%20Map%0A%20%20%20%20%20%20%20%20fig%20%3D%20px.scatter(%0A%20%20%20%20%20%20%20%20%20%20%20%20top_viz_df%2C%20%0A%20%20%20%20%20%20%20%20%20%20%20%20x%3D'X'%2C%20y%3D'Y'%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20hover_name%3D'Artist'%2C%20%20%20%20%20%20%20%23%20This%20makes%20the%20name%20pop%20up%20on%20hover!%0A%20%20%20%20%20%20%20%20%20%20%20%20size%3D'Plays'%2C%20%20%20%20%20%20%20%20%20%20%20%20%20%20%23%20Bigger%20dot%20%3D%20more%20plays%0A%20%20%20%20%20%20%20%20%20%20%20%20size_max%3D40%2C%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%23%20Cap%20the%20dot%20size%20so%20giant%20artists%20don't%20eclipse%20the%20map%0A%20%20%20%20%20%20%20%20%20%20%20%20title%3D%22My%20Musical%20Brain%3A%20Unsupervised%20Artist%20Clusters%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20opacity%3D0.75%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20template%3D%22plotly_dark%22%20%20%20%20%20%23%20Looks%20great%20for%20Spotify%20data%0A%20%20%20%20%20%20%20%20)%0A%0A%20%20%20%20%20%20%20%20%23%20Remove%20the%20confusing%20axis%20numbers%20(since%20t-SNE%20space%20is%20mathematically%20arbitrary)%0A%20%20%20%20%20%20%20%20fig.update_xaxes(showticklabels%3DFalse%2C%20title%3D%22%22)%0A%20%20%20%20%20%20%20%20fig.update_yaxes(showticklabels%3DFalse%2C%20title%3D%22%22)%0A%20%20%20%20%20%20%20%20return%20fig%0A%0A%0A%20%20%20%20_()%0A%20%20%20%20return%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%20Vector%20Arithmetic%20%E2%80%94%20Analogies%20%26%20Opposites%20(text)%0A%0A%20%20%20%20The%20famous%20word2vec%20party%20trick%2C%20on%20artists.%20Because%20the%2050-D%20vectors%20encode%0A%20%20%20%20association%20as%20*direction*%2C%20you%20can%20do%20algebra%20on%20taste%3A%0A%0A%20%20%20%20-%20**Analogy**%20%E2%80%94%20*A%20is%20to%20B%20as%20C%20is%20to%20%3F*%20Solve%0A%20%20%20%20%20%20%24%5C%3B%5Cvec%7BX%7D%20%3D%20%5Cvec%7BB%7D%20-%20%5Cvec%7BA%7D%20%2B%20%5Cvec%7BC%7D%5C%3B%24%20and%20return%20the%20nearest%20real%20artist%0A%20%20%20%20%20%20(excluding%20A%2FB%2FC).%20%24%5Cvec%7BB%7D-%5Cvec%7BA%7D%24%20isolates%20a%20%22concept%20direction%22%3B%20adding%20it%0A%20%20%20%20%20%20to%20%24%5Cvec%7BC%7D%24%20walks%20that%20same%20direction%20from%20a%20new%20starting%20point.%0A%20%20%20%20-%20**Polar%20opposite**%20%E2%80%94%20flip%20a%20vector%2C%20%24-%5Cvec%7Bv%7D%24%2C%20and%20find%20its%20nearest%20neighbor%3A%0A%20%20%20%20%20%20the%20artist%20on%20the%20far%20side%20of%20the%20space.%0A%0A%20%20%20%20This%20cell%20prints%20the%20text%20answers%3B%20the%20next%20sections%20turn%20the%20*same*%20math%20into%0A%20%20%20%203-D%20geometry%20you%20can%20actually%20see.%20Heads-up%20(explored%20later)%3A%20plain%20nearest-%0A%20%20%20%20neighbor%20here%20tends%20to%20snap%20to%20obscure%201-play%20artists%2C%20and%20the%20opposite%20query%20is%0A%20%20%20%20mathematically%20fragile%20%E2%80%94%20both%20are%20diagnosed%20and%20fixed%20downstream.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(%0A%20%20%20%20artist_to_idx%2C%0A%20%20%20%20artist_vectors%2C%0A%20%20%20%20cosine_similarity%2C%0A%20%20%20%20id_to_name%2C%0A%20%20%20%20unique_artists%2C%0A)%3A%0A%20%20%20%20def%20musical_analogy(artist_a%2C%20artist_b%2C%20artist_c%2C%20top_n%3D3)%3A%0A%20%20%20%20%20%20%20%20%22%22%22%0A%20%20%20%20%20%20%20%20Solves%3A%20Artist%20A%20is%20to%20Artist%20B%20as%20Artist%20C%20is%20to%20X.%0A%20%20%20%20%20%20%20%20Math%3A%20X%20%3D%20Vector(B)%20-%20Vector(A)%20%2B%20Vector(C)%0A%20%20%20%20%20%20%20%20%22%22%22%0A%20%20%20%20%20%20%20%20%23%201.%20Helper%20function%20to%20safely%20grab%20a%20vector%20by%20string%20name%0A%20%20%20%20%20%20%20%20def%20get_vec(name)%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20try%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20target_id%20%3D%20next(k%20for%20k%2C%20v%20in%20id_to_name.items()%20if%20v.lower()%20%3D%3D%20name.lower())%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20return%20artist_vectors%5Bartist_to_idx%5Btarget_id%5D%5D%0A%20%20%20%20%20%20%20%20%20%20%20%20except%20StopIteration%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20raise%20ValueError(f%22Artist%20'%7Bname%7D'%20not%20found%20in%20your%20matrix.%22)%0A%0A%20%20%20%20%20%20%20%20try%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20vec_a%20%3D%20get_vec(artist_a)%0A%20%20%20%20%20%20%20%20%20%20%20%20vec_b%20%3D%20get_vec(artist_b)%0A%20%20%20%20%20%20%20%20%20%20%20%20vec_c%20%3D%20get_vec(artist_c)%0A%20%20%20%20%20%20%20%20except%20ValueError%20as%20e%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20return%20print(e)%0A%0A%20%20%20%20%20%20%20%20%23%202.%20THE%20LINEAR%20ALGEBRA%0A%20%20%20%20%20%20%20%20%23%20We%20subtract%20A%20from%20B%20to%20find%20the%20%22concept%2Fdirection%22%20(e.g.%2C%20Underground%20-%3E%20Mainstream)%0A%20%20%20%20%20%20%20%20%23%20Then%20we%20add%20that%20concept%20to%20C%0A%20%20%20%20%20%20%20%20target_vector%20%3D%20(vec_b%20-%20vec_a)%20%2B%20vec_c%0A%20%20%20%20%20%20%20%20target_vector%20%3D%20target_vector.reshape(1%2C%20-1)%20%23%20Reshape%20for%20Scikit-Learn%0A%0A%20%20%20%20%20%20%20%20%23%203.%20Find%20the%20closest%20match%20in%20the%20whole%20database%0A%20%20%20%20%20%20%20%20similarities%20%3D%20cosine_similarity(target_vector%2C%20artist_vectors)%5B0%5D%0A%0A%20%20%20%20%20%20%20%20%23%20Sort%20from%20highest%20to%20lowest%20similarity%0A%20%20%20%20%20%20%20%20sorted_indices%20%3D%20similarities.argsort()%5B%3A%3A-1%5D%0A%0A%20%20%20%20%20%20%20%20print(f%22%5B%7Bartist_a%7D%5D%20is%20to%20%5B%7Bartist_b%7D%5D%20as%20%5B%7Bartist_c%7D%5D%20is%20to%3A%22)%0A%20%20%20%20%20%20%20%20print(%22-%22%20*%2040)%0A%0A%20%20%20%20%20%20%20%20count%20%3D%200%0A%20%20%20%20%20%20%20%20for%20idx%20in%20sorted_indices%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20match_name%20%3D%20id_to_name%5Bunique_artists%5Bidx%5D%5D%0A%0A%20%20%20%20%20%20%20%20%20%20%20%20%23%20We%20don't%20want%20the%20algorithm%20to%20just%20spit%20out%20A%2C%20B%2C%20or%20C%20as%20the%20answer!%0A%20%20%20%20%20%20%20%20%20%20%20%20if%20match_name.lower()%20not%20in%20%5Bartist_a.lower()%2C%20artist_b.lower()%2C%20artist_c.lower()%5D%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20print(f%22%7Bcount%20%2B%201%7D.%20%7Bmatch_name%7D%20(Score%3A%20%7Bsimilarities%5Bidx%5D%3A.3f%7D)%22)%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20count%20%2B%3D%201%0A%0A%20%20%20%20%20%20%20%20%20%20%20%20if%20count%20%3E%3D%20top_n%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20break%0A%0A%20%20%20%20def%20find_polar_opposite(artist_name)%3A%0A%20%20%20%20%20%20%20%20%23%20Get%20the%20target%20artist's%20ID%0A%20%20%20%20%20%20%20%20target_id%20%3D%20next(k%20for%20k%2C%20v%20in%20id_to_name.items()%20if%20v.lower()%20%3D%3D%20artist_name.lower())%0A%0A%20%20%20%20%20%20%20%20%23%20Get%20their%20vector%20and%20multiply%20it%20by%20-1%20(flipping%20it%20to%20the%20exact%20opposite%20side%20of%20the%20universe)%0A%20%20%20%20%20%20%20%20opposite_vector%20%3D%20(artist_vectors%5Bartist_to_idx%5Btarget_id%5D%5D%20*%20-1).reshape(1%2C%20-1)%0A%0A%20%20%20%20%20%20%20%20%23%20Find%20the%20nearest%20neighbors%20to%20this%20anti-coordinate%0A%20%20%20%20%20%20%20%20similarities%20%3D%20cosine_similarity(opposite_vector%2C%20artist_vectors)%5B0%5D%0A%20%20%20%20%20%20%20%20top_match_idx%20%3D%20similarities.argsort()%5B-1%5D%0A%0A%20%20%20%20%20%20%20%20match_name%20%3D%20id_to_name%5Bunique_artists%5Btop_match_idx%5D%5D%0A%0A%20%20%20%20%20%20%20%20print(f%22The%20mathematical%20polar%20opposite%20of%20%7Bartist_name.title()%7D%20is%3A%20%7Bmatch_name%7D%20(Score%3A%20%7Bsimilarities%5Btop_match_idx%5D%3A.3f%7D)%22)%0A%0A%20%20%20%20%23%20---%20TEST%20IT%20OUT%20---%0A%20%20%20%20%23%20Format%3A%20A%20is%20to%20B%20as%20C%20is%20to%20X%0A%20%20%20%20%23%20Example%3A%20%22If%20I%20take%20the%20'Pop'%20out%20of%20'Taylor%20Swift'%20and%20add%20'Metal'%2C%20who%20do%20I%20get%3F%22%0A%20%20%20%20musical_analogy(%22The%20Weeknd%22%2C%20%22Bad%20Omens%22%2C%20%22Michael%20Jackson%22)%0A%20%20%20%20print()%0A%20%20%20%20musical_analogy(%22BONES%22%2C%20%22Avenged%20Sevenfold%22%2C%20%22%24uicideboy%24%22)%0A%20%20%20%20print()%0A%20%20%20%20musical_analogy(%22Linkin%20Park%22%2C%20%22Knocked%20Loose%22%2C%20%22Nirvana%22)%20%23%20what%20is%20'modern'%20'angst'%0A%20%20%20%20print()%0A%20%20%20%20musical_analogy(%22Drake%22%2C%20%22Ghostemane%22%2C%20%22A%20Day%20To%20Remember%22)%0A%20%20%20%20print()%0A%20%20%20%20musical_analogy(%22Slipknot%22%2C%20%22Cayouche%22%2C%20%22%24uicideboy%24%22)%0A%0A%20%20%20%20print()%0A%20%20%20%20print(%22-%22%20*%2080)%0A%20%20%20%20print()%0A%0A%20%20%20%20find_polar_opposite(%22Motionless%20In%20White%22)%0A%20%20%20%20find_polar_opposite(%22BONES%22)%0A%20%20%20%20find_polar_opposite(%22Slipknot%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%20Analogies%20as%20Geometry%20%E2%80%94%203D%20PCA%0A%0A%20%20%20%20The%20analogy%20math%20%60X%20%3D%20B%20-%20A%20%2B%20C%60%20lives%20in%20the%2050D%20embedding.%20We%20project%20to%203D%0A%20%20%20%20with%20**PCA**%20(a%20*linear*%20map)%20rather%20than%20t-SNE%2FUMAP%20so%20the%20parallelogram%20is%0A%20%20%20%20preserved%20exactly%3A%20%60proj(B%20-%20A%20%2B%20C)%20%3D%3D%20proj(C)%20%2B%20(proj(B)%20-%20proj(A))%60.%20The%0A%20%20%20%20green%20concept-arrow%20and%20the%20dashed%20purple%20arrow%20are%20therefore%20provably%20parallel%0A%20%20%20%20and%20equal-length%20%E2%80%94%20the%20geometric%20heart%20of%20word2vec-style%20analogies.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(%0A%20%20%20%20M_co%2C%0A%20%20%20%20artist_to_idx%2C%0A%20%20%20%20artist_vectors%2C%0A%20%20%20%20cosine_similarity%2C%0A%20%20%20%20id_to_name%2C%0A%20%20%20%20np%2C%0A%20%20%20%20pd%2C%0A%20%20%20%20sessionized_plays_df%2C%0A%20%20%20%20unique_artists%2C%0A)%3A%0A%20%20%20%20from%20sklearn.decomposition%20import%20PCA%20as%20_PCA%0A%20%20%20%20import%20plotly.graph_objects%20as%20_go%0A%0A%20%20%20%20%23%20----%20vector%20helpers%20(50D%20space%20%E2%80%94%20where%20the%20real%20math%20lives)%20----%0A%20%20%20%20_name_to_id%20%3D%20%7Bv.lower()%3A%20k%20for%20k%2C%20v%20in%20id_to_name.items()%7D%0A%0A%20%20%20%20def%20_vec(name)%3A%0A%20%20%20%20%20%20%20%20return%20artist_vectors%5Bartist_to_idx%5B_name_to_id%5Bname.lower()%5D%5D%5D%0A%0A%20%20%20%20%23%20----%20answer-eligibility%20(per%20unique_artists%20index)%20----%0A%20%20%20%20%23%20Nearest-neighbor%20analogies%20snap%20to%201-play%20singletons%20(%22Magic%22)%20whose%20sparse%0A%20%20%20%20%23%20vectors%20win%20the%20cosine%20race.%20Restrict%20ANSWERS%20to%20a%20recognizable%20vocabulary%3A%0A%20%20%20%20%23%20%20-%20analogy%20-%3E%20min%20plays%20(recognizable%20artist)%0A%20%20%20%20%23%20%20-%20opposite%20-%3E%20min%20co-occurrence%20degree%20(meaningfully%20placed%2C%20but%20may%20be%20a%0A%20%20%20%20%23%20%20%20%20barely-played%20foreign%20artist%2C%20which%20is%20exactly%20the%20point%20of%20an%20opposite)%0A%20%20%20%20_plays_by_id%20%3D%20sessionized_plays_df%5B%22primary_artist_id%22%5D.value_counts()%0A%20%20%20%20_play_n%20%3D%20np.array(%5B_plays_by_id.get(a%2C%200)%20for%20a%20in%20unique_artists%5D)%0A%20%20%20%20_deg%20%3D%20np.diff(M_co.indptr)%0A%0A%20%20%20%20%23%20----%203D%20PROJECTION%3A%20PCA%2C%20*not*%20t-SNE%2FUMAP%2C%20on%20purpose%20----%0A%20%20%20%20%23%20PCA%20is%20a%20linear%20(affine)%20map%2C%20so%20the%20analogy%20parallelogram%20is%20preserved%0A%20%20%20%20%23%20exactly.%20t-SNE%2FUMAP%20are%20nonlinear%20and%20would%20bend%20these%20arrows%20into%20nonsense.%0A%20%20%20%20_pca%20%3D%20_PCA(n_components%3D3%2C%20random_state%3D0)%0A%20%20%20%20_coords3d%20%3D%20_pca.fit_transform(artist_vectors)%0A%20%20%20%20_P%20%3D%20pd.DataFrame(_coords3d%2C%20columns%3D%5B%22x%22%2C%20%22y%22%2C%20%22z%22%5D)%0A%20%20%20%20_P%5B%22Artist%22%5D%20%3D%20%5Bid_to_name%5Bunique_artists%5Bi%5D%5D%20for%20i%20in%20range(len(unique_artists))%5D%0A%20%20%20%20_plays%20%3D%20sessionized_plays_df%5B%22primary_artist_id%22%5D.value_counts()%0A%20%20%20%20_P%5B%22Plays%22%5D%20%3D%20%5B_plays.get(unique_artists%5Bi%5D%2C%201)%20for%20i%20in%20range(len(unique_artists))%5D%0A%0A%20%20%20%20def%20_proj(v)%3A%20%20%23%20project%20a%20raw%2050D%20vector%20into%20the%20same%203D%20space%0A%20%20%20%20%20%20%20%20return%20_pca.transform(v.reshape(1%2C%20-1))%5B0%5D%0A%0A%20%20%20%20def%20_arrow(fig%2C%20start%2C%20end%2C%20color%2C%20name%2C%20width%3D7%2C%20dash%3D%22solid%22)%3A%0A%20%20%20%20%20%20%20%20%23%20plotly%20has%20no%20native%203D%20arrow%3A%20line%20shaft%20%2B%20cone%20head%0A%20%20%20%20%20%20%20%20fig.add_trace(_go.Scatter3d(%0A%20%20%20%20%20%20%20%20%20%20%20%20x%3D%5Bstart%5B0%5D%2C%20end%5B0%5D%5D%2C%20y%3D%5Bstart%5B1%5D%2C%20end%5B1%5D%5D%2C%20z%3D%5Bstart%5B2%5D%2C%20end%5B2%5D%5D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20mode%3D%22lines%22%2C%20line%3Ddict(color%3Dcolor%2C%20width%3Dwidth%2C%20dash%3Ddash)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20name%3Dname%2C%20hoverinfo%3D%22name%22%2C%0A%20%20%20%20%20%20%20%20))%0A%20%20%20%20%20%20%20%20d%20%3D%20end%20-%20start%0A%20%20%20%20%20%20%20%20fig.add_trace(_go.Cone(%0A%20%20%20%20%20%20%20%20%20%20%20%20x%3D%5Bend%5B0%5D%5D%2C%20y%3D%5Bend%5B1%5D%5D%2C%20z%3D%5Bend%5B2%5D%5D%2C%20u%3D%5Bd%5B0%5D%5D%2C%20v%3D%5Bd%5B1%5D%5D%2C%20w%3D%5Bd%5B2%5D%5D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20sizemode%3D%22absolute%22%2C%20sizeref%3Dmax(float(np.linalg.norm(d))%20*%200.18%2C%201e-6)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20anchor%3D%22tip%22%2C%20showscale%3DFalse%2C%20hoverinfo%3D%22skip%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20colorscale%3D%5B%5B0%2C%20color%5D%2C%20%5B1%2C%20color%5D%5D%2C%0A%20%20%20%20%20%20%20%20))%0A%0A%20%20%20%20def%20show_analogy(a%2C%20b%2C%20c%2C%20context%3D400%2C%20pad%3D1.8%2C%20min_plays%3D25)%3A%0A%20%20%20%20%20%20%20%20%22%22%22A%20is%20to%20B%20as%20C%20is%20to%20X%20%E2%80%94%20drawn%20as%20parallel%20vectors%20in%203D%20PCA%20space.%0A%0A%20%20%20%20%20%20%20%20%60min_plays%60%20restricts%20the%20answer%20to%20a%20recognizable%20vocabulary%20so%20the%0A%20%20%20%20%20%20%20%20nearest-neighbor%20doesn't%20snap%20to%20a%201-play%20singleton%20outlier.%0A%20%20%20%20%20%20%20%20%22%22%22%0A%20%20%20%20%20%20%20%20va%2C%20vb%2C%20vc%20%3D%20_vec(a)%2C%20_vec(b)%2C%20_vec(c)%0A%20%20%20%20%20%20%20%20target%20%3D%20vb%20-%20va%20%2B%20vc%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%23%20the%20real%20analogy%20math%2C%20in%2050D%0A%20%20%20%20%20%20%20%20sims%20%3D%20cosine_similarity(target.reshape(1%2C%20-1)%2C%20artist_vectors)%5B0%5D%0A%20%20%20%20%20%20%20%20banned%20%3D%20%7Ba.lower()%2C%20b.lower()%2C%20c.lower()%7D%0A%20%20%20%20%20%20%20%20elig%20%3D%20_play_n%20%3E%3D%20min_plays%0A%20%20%20%20%20%20%20%20answer%20%3D%20next(id_to_name%5Bunique_artists%5Bi%5D%5D%20for%20i%20in%20sims.argsort()%5B%3A%3A-1%5D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20if%20elig%5Bi%5D%20and%20id_to_name%5Bunique_artists%5Bi%5D%5D.lower()%20not%20in%20banned)%0A%0A%20%20%20%20%20%20%20%20A3%2C%20B3%2C%20C3%20%3D%20_proj(va)%2C%20_proj(vb)%2C%20_proj(vc)%0A%20%20%20%20%20%20%20%20Pred%20%3D%20C3%20%2B%20(B3%20-%20A3)%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%23%20exact%20in%20PCA%20space%0A%20%20%20%20%20%20%20%20X3%20%3D%20_proj(_vec(answer))%0A%0A%20%20%20%20%20%20%20%20fig%20%3D%20_go.Figure()%0A%20%20%20%20%20%20%20%20bg%20%3D%20_P.nlargest(context%2C%20%22Plays%22)%0A%20%20%20%20%20%20%20%20fig.add_trace(_go.Scatter3d(%0A%20%20%20%20%20%20%20%20%20%20%20%20x%3Dbg.x%2C%20y%3Dbg.y%2C%20z%3Dbg.z%2C%20mode%3D%22markers%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20marker%3Ddict(size%3D2.5%2C%20color%3D%22%23444%22%2C%20opacity%3D0.45)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20text%3Dbg.Artist%2C%20hoverinfo%3D%22text%22%2C%20name%3D%22other%20artists%22%2C%0A%20%20%20%20%20%20%20%20))%0A%0A%20%20%20%20%20%20%20%20_arrow(fig%2C%20A3%2C%20B3%2C%20%22%2300cc96%22%2C%20f%22concept%3A%20%20%7Ba%7D%20%E2%86%92%20%7Bb%7D%22)%0A%20%20%20%20%20%20%20%20_arrow(fig%2C%20C3%2C%20Pred%2C%20%22%23ab63fa%22%2C%20f%22same%20vector%20applied%20at%20%7Bc%7D%22%2C%20dash%3D%22dash%22)%0A%20%20%20%20%20%20%20%20_arrow(fig%2C%20C3%2C%20X3%2C%20%22%23ffa15a%22%2C%20f%22%7Bc%7D%20%E2%86%92%20%7Banswer%7D%20%20(nearest%20real%20artist)%22)%0A%0A%20%20%20%20%20%20%20%20pts%20%3D%20%7Ba%3A%20A3%2C%20b%3A%20B3%2C%20c%3A%20C3%2C%20answer%3A%20X3%7D%0A%20%20%20%20%20%20%20%20fig.add_trace(_go.Scatter3d(%0A%20%20%20%20%20%20%20%20%20%20%20%20x%3D%5Bp%5B0%5D%20for%20p%20in%20pts.values()%5D%2C%20y%3D%5Bp%5B1%5D%20for%20p%20in%20pts.values()%5D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20z%3D%5Bp%5B2%5D%20for%20p%20in%20pts.values()%5D%2C%20mode%3D%22markers%2Btext%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20marker%3Ddict(size%3D7%2C%20color%3D%5B%22%2300cc96%22%2C%20%22%2300cc96%22%2C%20%22%23ab63fa%22%2C%20%22%23ffa15a%22%5D)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20text%3Dlist(pts.keys())%2C%20textposition%3D%22top%20center%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20textfont%3Ddict(size%3D14%2C%20color%3D%22white%22)%2C%20name%3D%22anchors%22%2C%0A%20%20%20%20%20%20%20%20))%0A%20%20%20%20%20%20%20%20fig.add_trace(_go.Scatter3d(%0A%20%20%20%20%20%20%20%20%20%20%20%20x%3D%5BPred%5B0%5D%5D%2C%20y%3D%5BPred%5B1%5D%5D%2C%20z%3D%5BPred%5B2%5D%5D%2C%20mode%3D%22markers%2Btext%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20marker%3Ddict(size%3D6%2C%20color%3D%22%23ab63fa%22%2C%20symbol%3D%22x%22)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20text%3D%5B%22predicted%20X%22%5D%2C%20textposition%3D%22bottom%20center%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20textfont%3Ddict(size%3D10%2C%20color%3D%22%23ab63fa%22)%2C%20name%3D%22predicted%20X%22%2C%0A%20%20%20%20%20%20%20%20))%0A%0A%20%20%20%20%20%20%20%20%23%20---%20auto-frame%3A%20tight%20CUBIC%20box%20around%20the%20analogy%20so%20we%20can%20zoom%20in%20---%0A%20%20%20%20%20%20%20%20%23%20Plotly's%20scroll-zoom%20won't%20get%20close%20enough%20on%20its%20own%3B%20clamping%20the%20axis%0A%20%20%20%20%20%20%20%20%23%20ranges%20to%20a%20small%20cube%20around%20the%20key%20points%20reframes%20onto%20just%20this%0A%20%20%20%20%20%20%20%20%23%20neighborhood.%20Equal-length%20ranges%20%2B%20aspectmode%3D%22cube%22%20keep%20the%20scale%0A%20%20%20%20%20%20%20%20%23%20isometric%20so%20the%20parallel%20arrows%20stay%20parallel.%20%60pad%60%20sets%20the%20margin%0A%20%20%20%20%20%20%20%20%23%20(smaller%20%3D%20tighter%20crop).%0A%20%20%20%20%20%20%20%20key%20%3D%20np.array(%5BA3%2C%20B3%2C%20C3%2C%20X3%2C%20Pred%5D)%0A%20%20%20%20%20%20%20%20center%20%3D%20key.mean(axis%3D0)%0A%20%20%20%20%20%20%20%20half%20%3D%20max(float((key.max(axis%3D0)%20-%20key.min(axis%3D0)).max())%20%2F%202%2C%201e-6)%20*%20pad%0A%20%20%20%20%20%20%20%20rng%20%3D%20%5B%5Bcenter%5Bi%5D%20-%20half%2C%20center%5Bi%5D%20%2B%20half%5D%20for%20i%20in%20range(3)%5D%0A%0A%20%20%20%20%20%20%20%20ev%20%3D%20_pca.explained_variance_ratio_%5B%3A3%5D.sum()%0A%20%20%20%20%20%20%20%20fig.update_layout(%0A%20%20%20%20%20%20%20%20%20%20%20%20template%3D%22plotly_dark%22%2C%20height%3D760%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20title%3D(f%22%7Ba%7D%20%E2%86%92%20%7Bb%7D%20%20%3A%3A%20%20%7Bc%7D%20%E2%86%92%20%3Cb%3E%7Banswer%7D%3C%2Fb%3E%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20f%22%3Cbr%3E%3Csup%3Egreen%20%E2%88%A5%20dashed-purple%20%3D%20the%20analogy%20holds%20%C2%B7%20%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20f%22predicted-X%20vs%20%7Banswer%7D%20gap%20also%20reflects%20the%20%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20f%22%7B1%20-%20ev%3A.0%25%7D%20of%20variance%20the%203%20PCs%20can't%20show%3C%2Fsup%3E%22)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20scene%3Ddict(%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20xaxis%3Ddict(title%3D%22PC1%22%2C%20range%3Drng%5B0%5D)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20yaxis%3Ddict(title%3D%22PC2%22%2C%20range%3Drng%5B1%5D)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20zaxis%3Ddict(title%3D%22PC3%22%2C%20range%3Drng%5B2%5D)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20aspectmode%3D%22cube%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20legend%3Ddict(orientation%3D%22h%22%2C%20y%3D-0.04)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20margin%3Ddict(l%3D0%2C%20r%3D0%2C%20t%3D80%2C%20b%3D0)%2C%0A%20%20%20%20%20%20%20%20)%0A%20%20%20%20%20%20%20%20return%20fig%0A%0A%20%20%20%20def%20show_opposite(artist%2C%20context%3D400%2C%20pad%3D1.8%2C%20min_deg%3D15)%3A%0A%20%20%20%20%20%20%20%20%22%22%22The%20mathematical%20antipode%20(%E2%88%92vector)%20of%20an%20artist%2C%20in%203D%20PCA%20space.%0A%0A%20%20%20%20%20%20%20%20%60min_deg%60%20keeps%20the%20match%20meaningfully%20placed%20(excludes%201-play%20singletons)%0A%20%20%20%20%20%20%20%20while%20still%20allowing%20barely-played%20foreign%20artists%20%E2%80%94%20the%20whole%20point%20of%20an%0A%20%20%20%20%20%20%20%20'opposite'%20is%20something%20you%20almost%20never%20listen%20to.%0A%20%20%20%20%20%20%20%20%22%22%22%0A%20%20%20%20%20%20%20%20v%20%3D%20_vec(artist)%0A%20%20%20%20%20%20%20%20sims%20%3D%20cosine_similarity((-v).reshape(1%2C%20-1)%2C%20artist_vectors)%5B0%5D%0A%20%20%20%20%20%20%20%20order%20%3D%20sims.argsort()%5B%3A%3A-1%5D%0A%20%20%20%20%20%20%20%20elig%20%3D%20_deg%20%3E%3D%20min_deg%0A%20%20%20%20%20%20%20%20answer_i%20%3D%20next(i%20for%20i%20in%20order%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20if%20elig%5Bi%5D%20and%20id_to_name%5Bunique_artists%5Bi%5D%5D.lower()%20!%3D%20artist.lower())%0A%20%20%20%20%20%20%20%20answer%20%3D%20id_to_name%5Bunique_artists%5Banswer_i%5D%5D%0A%20%20%20%20%20%20%20%20sim_opp%20%3D%20float(sims%5Banswer_i%5D)%0A%0A%20%20%20%20%20%20%20%20P3%20%3D%20_proj(v)%0A%20%20%20%20%20%20%20%20Anti3%20%3D%20_proj(-v)%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%23%20exact%20antipode%0A%20%20%20%20%20%20%20%20X3%20%3D%20_proj(_vec(answer))%0A%20%20%20%20%20%20%20%20Pivot%20%3D%20_proj(np.zeros_like(v))%20%20%20%20%20%20%20%20%20%23%2050D%20origin%20%3D%20reflection%20pivot%0A%0A%20%20%20%20%20%20%20%20fig%20%3D%20_go.Figure()%0A%20%20%20%20%20%20%20%20bg%20%3D%20_P.nlargest(context%2C%20%22Plays%22)%0A%20%20%20%20%20%20%20%20fig.add_trace(_go.Scatter3d(%0A%20%20%20%20%20%20%20%20%20%20%20%20x%3Dbg.x%2C%20y%3Dbg.y%2C%20z%3Dbg.z%2C%20mode%3D%22markers%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20marker%3Ddict(size%3D2.5%2C%20color%3D%22%23444%22%2C%20opacity%3D0.45)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20text%3Dbg.Artist%2C%20hoverinfo%3D%22text%22%2C%20name%3D%22other%20artists%22%2C%0A%20%20%20%20%20%20%20%20))%0A%20%20%20%20%20%20%20%20%23%20the%20flip%3A%20artist%20-%3E%20its%20exact%20antipode%20(through%20the%20taste-center)%0A%20%20%20%20%20%20%20%20_arrow(fig%2C%20P3%2C%20Anti3%2C%20%22%23ef553b%22%2C%20f%22%7Bartist%7D%20%E2%86%92%20%E2%88%92vector%20(exact%20opposite)%22)%0A%20%20%20%20%20%20%20%20%23%20gap%20from%20the%20math's%20antipode%20to%20the%20nearest%20*real*%20artist%0A%20%20%20%20%20%20%20%20_arrow(fig%2C%20Anti3%2C%20X3%2C%20%22%23ffa15a%22%2C%20f%22nearest%20real%20artist%20%E2%86%92%20%7Banswer%7D%22%2C%20dash%3D%22dash%22)%0A%0A%20%20%20%20%20%20%20%20pts%20%3D%20%7Bartist%3A%20P3%2C%20answer%3A%20X3%7D%0A%20%20%20%20%20%20%20%20fig.add_trace(_go.Scatter3d(%0A%20%20%20%20%20%20%20%20%20%20%20%20x%3D%5Bp%5B0%5D%20for%20p%20in%20pts.values()%5D%2C%20y%3D%5Bp%5B1%5D%20for%20p%20in%20pts.values()%5D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20z%3D%5Bp%5B2%5D%20for%20p%20in%20pts.values()%5D%2C%20mode%3D%22markers%2Btext%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20marker%3Ddict(size%3D7%2C%20color%3D%5B%22%2319d3f3%22%2C%20%22%23ffa15a%22%5D)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20text%3Dlist(pts.keys())%2C%20textposition%3D%22top%20center%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20textfont%3Ddict(size%3D12%2C%20color%3D%22white%22)%2C%20name%3D%22anchors%22%2C%0A%20%20%20%20%20%20%20%20))%0A%20%20%20%20%20%20%20%20fig.add_trace(_go.Scatter3d(%0A%20%20%20%20%20%20%20%20%20%20%20%20x%3D%5BAnti3%5B0%5D%5D%2C%20y%3D%5BAnti3%5B1%5D%5D%2C%20z%3D%5BAnti3%5B2%5D%5D%2C%20mode%3D%22markers%2Btext%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20marker%3Ddict(size%3D6%2C%20color%3D%22%23ef553b%22%2C%20symbol%3D%22x%22)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20text%3D%5B%22exact%20%E2%88%92vector%22%5D%2C%20textposition%3D%22bottom%20center%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20textfont%3Ddict(size%3D10%2C%20color%3D%22%23ef553b%22)%2C%20name%3D%22antipode%22%2C%0A%20%20%20%20%20%20%20%20))%0A%20%20%20%20%20%20%20%20fig.add_trace(_go.Scatter3d(%0A%20%20%20%20%20%20%20%20%20%20%20%20x%3D%5BPivot%5B0%5D%5D%2C%20y%3D%5BPivot%5B1%5D%5D%2C%20z%3D%5BPivot%5B2%5D%5D%2C%20mode%3D%22markers%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20marker%3Ddict(size%3D4%2C%20color%3D%22%23888%22)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20name%3D%22reflection%20center%22%2C%20hoverinfo%3D%22name%22%2C%0A%20%20%20%20%20%20%20%20))%0A%0A%20%20%20%20%20%20%20%20key%20%3D%20np.array(%5BP3%2C%20Anti3%2C%20X3%5D)%0A%20%20%20%20%20%20%20%20center%20%3D%20key.mean(axis%3D0)%0A%20%20%20%20%20%20%20%20half%20%3D%20max(float((key.max(axis%3D0)%20-%20key.min(axis%3D0)).max())%20%2F%202%2C%201e-6)%20*%20pad%0A%20%20%20%20%20%20%20%20rng%20%3D%20%5B%5Bcenter%5Bi%5D%20-%20half%2C%20center%5Bi%5D%20%2B%20half%5D%20for%20i%20in%20range(3)%5D%0A%20%20%20%20%20%20%20%20fig.update_layout(%0A%20%20%20%20%20%20%20%20%20%20%20%20template%3D%22plotly_dark%22%2C%20height%3D760%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20title%3D(f%22The%20exact%20opposite%20of%20%3Cb%3E%7Bartist%7D%3C%2Fb%3E%20%E2%86%92%20%3Cb%3E%7Banswer%7D%3C%2Fb%3E%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20f%22%3Cbr%3E%3Csup%3Eantipode%20%3D%20%E2%88%92vector%20through%20the%20taste-center%20%C2%B7%20best%20%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20f%22match%20only%20cos%E2%89%88%7Bsim_opp%3A.2f%7D%3A%20your%20library%20fills%20one%20%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20f%22hemisphere%2C%20so%20%E2%80%9Copposite%E2%80%9D%20lands%20in%20near-empty%20space%3C%2Fsup%3E%22)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20scene%3Ddict(%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20xaxis%3Ddict(title%3D%22PC1%22%2C%20range%3Drng%5B0%5D)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20yaxis%3Ddict(title%3D%22PC2%22%2C%20range%3Drng%5B1%5D)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20zaxis%3Ddict(title%3D%22PC3%22%2C%20range%3Drng%5B2%5D)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20aspectmode%3D%22cube%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20legend%3Ddict(orientation%3D%22h%22%2C%20y%3D-0.04)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20margin%3Ddict(l%3D0%2C%20r%3D0%2C%20t%3D80%2C%20b%3D0)%2C%0A%20%20%20%20%20%20%20%20)%0A%20%20%20%20%20%20%20%20return%20fig%0A%0A%20%20%20%20return%20show_analogy%2C%20show_opposite%0A%0A%0A%40app.cell%0Adef%20_(id_to_name%2C%20mo%2C%20sessionized_plays_df)%3A%0A%20%20%20%20%23%20searchable%20dropdowns%2C%20ordered%20by%20how%20much%20you%20actually%20played%20each%20artist%0A%20%20%20%20_plays%20%3D%20sessionized_plays_df%5B%22primary_artist_id%22%5D.value_counts()%0A%20%20%20%20_ranked%20%3D%20%5Bid_to_name%5Ba%5D%20for%20a%20in%20_plays.index%20if%20a%20in%20id_to_name%5D%0A%20%20%20%20_seen%20%3D%20set()%0A%20%20%20%20artist_options%20%3D%20%5Bn%20for%20n%20in%20_ranked%20if%20not%20(n%20in%20_seen%20or%20_seen.add(n))%5D%0A%0A%20%20%20%20def%20_dd(default)%3A%0A%20%20%20%20%20%20%20%20return%20mo.ui.dropdown(%0A%20%20%20%20%20%20%20%20%20%20%20%20options%3Dartist_options%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20value%3Ddefault%20if%20default%20in%20artist_options%20else%20artist_options%5B0%5D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20searchable%3DTrue%2C%0A%20%20%20%20%20%20%20%20)%0A%0A%20%20%20%20a_dd%2C%20b_dd%2C%20c_dd%20%3D%20_dd(%22Creed%22)%2C%20_dd(%22Future%22)%2C%20_dd(%22Red%20Hot%20Chili%20Peppers%22)%0A%20%20%20%20zoom%20%3D%20mo.ui.slider(start%3D1.1%2C%20stop%3D5.0%2C%20step%3D0.1%2C%20value%3D1.8%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20label%3D%22crop%20(smaller%20%3D%20tighter)%22%2C%20show_value%3DTrue)%0A%0A%20%20%20%20mo.md(f%22%22%22%0A%20%20%20%20**A%20is%20to%20B%20%20%20as%20%20%20C%20is%20to%20%E2%80%A6%20X**%0A%0A%20%20%20%20%7C%20A%20(start)%20%7C%20B%20(end)%20%7C%20C%20(start)%20%7C%0A%20%20%20%20%7C---%7C---%7C---%7C%0A%20%20%20%20%7C%20%7Ba_dd%7D%20%7C%20%7Bb_dd%7D%20%7C%20%7Bc_dd%7D%20%7C%0A%0A%20%20%20%20%7Bzoom%7D%0A%0A%20%20%20%20*Read%20the%20green%20arrow%20as%20the%20%E2%80%9Cconcept%E2%80%9D%20B%20%E2%88%92%20A%3B%20the%20dashed%20purple%20arrow%20re-applies%0A%20%20%20%20that%20exact%20vector%20at%20C%20to%20predict%20X.*%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%20(zoom%2C)%0A%0A%0A%40app.cell%0Adef%20_(show_analogy%2C%20zoom)%3A%0A%20%20%20%20show_analogy(%22311%22%2C%20%22BONES%22%2C%20%22Hoobastank%22%2C%20pad%3Dzoom.value)%0A%20%20%20%20return%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%20The%20Exact%20Opposite%0A%0A%20%20%20%20Flip%20an%20artist's%20vector%20to%20its%20antipode%20(%60%E2%88%92v%60)%20and%20find%20the%20nearest%20*real*%0A%20%20%20%20artist%20on%20the%20far%20side.%20The%20punchline%20is%20geometric%3A%20the%20best%20match%20is%20only%0A%20%20%20%20~cos%200.05%2C%20because%20your%20entire%20library%20occupies%20a%20single%20hemisphere%20of%20the%0A%20%20%20%20space%20%E2%80%94%20there%20is%20no%20true%20%22negative%20pole%2C%22%20so%20the%20opposite%20lands%20in%20near-empty%0A%20%20%20%20territory.%20Recognizable%20examples%3A%20*Wage%20War%20%E2%86%92%20REO%20Speedwagon*%2C%0A%20%20%20%20*Lorna%20Shore%20%E2%86%92%20Grand%20Corps%20Malade*.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(id_to_name%2C%20mo%2C%20sessionized_plays_df)%3A%0A%20%20%20%20_plays%20%3D%20sessionized_plays_df%5B%22primary_artist_id%22%5D.value_counts()%0A%20%20%20%20_ranked%20%3D%20%5Bid_to_name%5Ba%5D%20for%20a%20in%20_plays.index%20if%20a%20in%20id_to_name%5D%0A%20%20%20%20_seen%20%3D%20set()%0A%20%20%20%20_opts%20%3D%20%5Bn%20for%20n%20in%20_ranked%20if%20not%20(n%20in%20_seen%20or%20_seen.add(n))%5D%0A%0A%20%20%20%20o_dd%20%3D%20mo.ui.dropdown(%0A%20%20%20%20%20%20%20%20options%3D_opts%2C%0A%20%20%20%20%20%20%20%20value%3D%22Wage%20War%22%20if%20%22Wage%20War%22%20in%20_opts%20else%20_opts%5B0%5D%2C%0A%20%20%20%20%20%20%20%20searchable%3DTrue%2C%0A%20%20%20%20)%0A%20%20%20%20o_zoom%20%3D%20mo.ui.slider(start%3D1.1%2C%20stop%3D5.0%2C%20step%3D0.1%2C%20value%3D1.8%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20label%3D%22crop%20(smaller%20%3D%20tighter)%22%2C%20show_value%3DTrue)%0A%0A%20%20%20%20mo.md(f%22%22%22%0A%20%20%20%20**Find%20the%20exact%20opposite%20of%3A**%20%20%7Bo_dd%7D%0A%0A%20%20%20%20%7Bo_zoom%7D%0A%0A%20%20%20%20*Red%20arrow%20%3D%20flip%20to%20the%20antipode%20(%E2%88%92vector)%3B%20dashed%20arrow%20%3D%20the%20gap%20to%20the%0A%20%20%20%20closest%20real%20artist%20that%20actually%20exists%20over%20there.*%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%20o_dd%2C%20o_zoom%0A%0A%0A%40app.cell%0Adef%20_(o_dd%2C%20o_zoom%2C%20show_opposite)%3A%0A%20%20%20%20show_opposite(o_dd.value%2C%20pad%3Do_zoom.value)%0A%20%20%20%20return%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%20A%20*True*%20Opposite%20%E2%80%94%20Signed%20PMI%20on%20the%20Core%0A%0A%20%20%20%20The%20void%20above%20is%20a%20direct%20consequence%20of%20the%20**Positive**%20in%20PPMI%3A%20clipping%0A%20%20%20%20negative%20PMI%20to%20zero%20erases%20the%20only%20signal%20that%20could%20encode%20opposition%2C%20so%0A%20%20%20%20%60%E2%88%92v%60%20just%20points%20into%20empty%20space.%0A%0A%20%20%20%20Here%20we%20keep%20the%20sign.%20For%20the%20**top-150%20most-played%20artists**%20(the%20only%20ones%0A%20%20%20%20where%20co-occurrence%20is%20estimated%20well%20enough%20to%20trust%20a%20*negative*)%2C%20we%20compute%0A%0A%20%20%20%20%24%24%20%5Cmathrm%7BsPMI%7D_%7Bij%7D%20%3D%20%5Clog_2%5C!%5Cfrac%7BO_%7Bij%7D%20%2B%200.5%7D%7BE_%7Bij%7D%7D%2C%5Cqquad%0A%20%20%20%20%20%20%20E_%7Bij%7D%20%3D%20%5Cfrac%7BS_i%5C%2CS_j%7D%7BN%7D%20%24%24%0A%0A%20%20%20%20where%20%24O_%7Bij%7D%24%20is%20sessions%20where%20%24i%24%20and%20%24j%24%20co-occur%2C%20%24S_i%24%20is%20sessions%0A%20%20%20%20containing%20%24i%24%2C%20and%20%24N%24%20is%20total%20sessions.%20%24E_%7Bij%7D%24%20is%20the%20co-occurrence%20you'd%0A%20%20%20%20expect%20if%20listening%20were%20independent%3B%20%24O%20%3C%20E%24%20%E2%87%92%20**negative**%20sPMI%20%E2%87%92%20two%20artists%0A%20%20%20%20that%20systematically%20*avoid*%20each%20other's%20sessions.%20Each%20artist's%20row%20is%20its%0A%20%20%20%20signed%20co-listening%20**profile**%3B%20two%20artists%20are%20true%20opposites%20when%20their%0A%20%20%20%20profiles%20are%20anti-correlated.%0A%0A%20%20%20%20The%20payoff%3A%20profile-cosine%20now%20spans%20roughly%20**%E2%88%920.7%20to%20%2B0.9**%20(PPMI%20maxed%20out%0A%20%20%20%20near%200%20on%20the%20negative%20side)%2C%20and%20~11%25%20of%20core%20pairs%20are%20genuinely%20opposed.%0A%20%20%20%20The%20opposition%20is%20*contextual*%20%E2%80%94%20artists%20from%20listening%20modes%20that%20exclude%20one%0A%20%20%20%20another%20%E2%80%94%20not%20strictly%20sonic.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(np%2C%20pd%2C%20sessionized_plays_df)%3A%0A%20%20%20%20from%20sklearn.decomposition%20import%20PCA%20as%20_PCA%0A%20%20%20%20import%20plotly.graph_objects%20as%20_go%0A%20%20%20%20from%20itertools%20import%20combinations%20as%20_comb%0A%0A%20%20%20%20%23%20---%20signed-PMI%20on%20the%20well-estimated%20CORE%20(top-150%20by%20plays)%20---%0A%20%20%20%20_sessions%20%3D%20sessionized_plays_df.groupby(%22session_id%22)%5B%22primary_artist_id%22%5D.apply(%0A%20%20%20%20%20%20%20%20lambda%20x%3A%20sorted(set(x)))%0A%20%20%20%20_N%20%3D%20_sessions.shape%5B0%5D%0A%20%20%20%20_plays%20%3D%20sessionized_plays_df%5B%22primary_artist_id%22%5D.value_counts()%0A%20%20%20%20_id2name%20%3D%20(sessionized_plays_df.drop_duplicates(%22primary_artist_id%22)%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20.set_index(%22primary_artist_id%22)%5B%22name%22%5D.to_dict())%0A%20%20%20%20_TOPN%20%3D%20150%0A%20%20%20%20_core%20%3D%20list(_plays.index%5B%3A_TOPN%5D)%0A%20%20%20%20_ix%20%3D%20%7Ba%3A%20i%20for%20i%2C%20a%20in%20enumerate(_core)%7D%0A%20%20%20%20_names%20%3D%20np.array(%5Bstr(_id2name.get(a%2C%20a))%20for%20a%20in%20_core%5D)%0A%0A%20%20%20%20_S%20%3D%20np.zeros(_TOPN)%0A%20%20%20%20_obs%20%3D%20np.zeros((_TOPN%2C%20_TOPN))%0A%20%20%20%20for%20_sa%20in%20_sessions%3A%0A%20%20%20%20%20%20%20%20_cs%20%3D%20%5Ba%20for%20a%20in%20_sa%20if%20a%20in%20_ix%5D%0A%20%20%20%20%20%20%20%20for%20_a%20in%20_cs%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20_S%5B_ix%5B_a%5D%5D%20%2B%3D%201%0A%20%20%20%20%20%20%20%20for%20_a%2C%20_b%20in%20_comb(_cs%2C%202)%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20_i%2C%20_j%20%3D%20_ix%5B_a%5D%2C%20_ix%5B_b%5D%0A%20%20%20%20%20%20%20%20%20%20%20%20_obs%5B_i%2C%20_j%5D%20%2B%3D%201%0A%20%20%20%20%20%20%20%20%20%20%20%20_obs%5B_j%2C%20_i%5D%20%2B%3D%201%0A%20%20%20%20_E%20%3D%20np.outer(_S%2C%20_S)%20%2F%20_N%0A%20%20%20%20np.fill_diagonal(_E%2C%201)%0A%20%20%20%20_spmi%20%3D%20np.log2((_obs%20%2B%200.5)%20%2F%20_E)%20%20%20%20%20%20%20%20%20%20%23%20SIGNED%20%E2%80%94%20no%20clip%0A%20%20%20%20np.fill_diagonal(_spmi%2C%200.0)%0A%0A%20%20%20%20%23%20each%20row%20%3D%20signed%20co-listening%20profile%3B%20anti-correlated%20profiles%20%3D%20opposites%0A%20%20%20%20_Xc%20%3D%20_spmi%20-%20_spmi.mean(axis%3D0%2C%20keepdims%3DTrue)%0A%20%20%20%20_Xn%20%3D%20_Xc%20%2F%20(np.linalg.norm(_Xc%2C%20axis%3D1%2C%20keepdims%3DTrue)%20%2B%201e-12)%0A%20%20%20%20_COS%20%3D%20_Xn%20%40%20_Xn.T%0A%20%20%20%20_coords%20%3D%20_PCA(n_components%3D3%2C%20random_state%3D0).fit_transform(_Xc)%0A%20%20%20%20_CO%20%3D%20pd.DataFrame(_coords%2C%20columns%3D%5B%22x%22%2C%20%22y%22%2C%20%22z%22%5D)%0A%20%20%20%20_CO%5B%22Artist%22%5D%20%3D%20_names%0A%20%20%20%20_name2i%20%3D%20%7Bn.lower()%3A%20i%20for%20i%2C%20n%20in%20enumerate(_names)%7D%0A%20%20%20%20core_opposite_artists%20%3D%20sorted(_names.tolist())%0A%0A%20%20%20%20def%20_arrow(fig%2C%20s%2C%20e%2C%20color%2C%20label%2C%20dash%3D%22solid%22)%3A%0A%20%20%20%20%20%20%20%20fig.add_trace(_go.Scatter3d(%0A%20%20%20%20%20%20%20%20%20%20%20%20x%3D%5Bs%5B0%5D%2C%20e%5B0%5D%5D%2C%20y%3D%5Bs%5B1%5D%2C%20e%5B1%5D%5D%2C%20z%3D%5Bs%5B2%5D%2C%20e%5B2%5D%5D%2C%20mode%3D%22lines%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20line%3Ddict(color%3Dcolor%2C%20width%3D7%2C%20dash%3Ddash)%2C%20name%3Dlabel%2C%20hoverinfo%3D%22name%22))%0A%20%20%20%20%20%20%20%20d%20%3D%20e%20-%20s%0A%20%20%20%20%20%20%20%20fig.add_trace(_go.Cone(%0A%20%20%20%20%20%20%20%20%20%20%20%20x%3D%5Be%5B0%5D%5D%2C%20y%3D%5Be%5B1%5D%5D%2C%20z%3D%5Be%5B2%5D%5D%2C%20u%3D%5Bd%5B0%5D%5D%2C%20v%3D%5Bd%5B1%5D%5D%2C%20w%3D%5Bd%5B2%5D%5D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20sizemode%3D%22absolute%22%2C%20sizeref%3Dmax(float(np.linalg.norm(d))%20*%200.18%2C%201e-6)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20anchor%3D%22tip%22%2C%20showscale%3DFalse%2C%20hoverinfo%3D%22skip%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20colorscale%3D%5B%5B0%2C%20color%5D%2C%20%5B1%2C%20color%5D%5D))%0A%0A%20%20%20%20def%20show_true_opposite(artist%2C%20pad%3D1.6)%3A%0A%20%20%20%20%20%20%20%20%22%22%22Signed-PMI%20true%20opposite%3A%20the%20most%20anti-correlated%20co-listening%20profile.%22%22%22%0A%20%20%20%20%20%20%20%20i%20%3D%20_name2i%5Bartist.lower()%5D%0A%20%20%20%20%20%20%20%20opp_i%20%3D%20int(np.argsort(_COS%5Bi%5D)%5B0%5D)%20%20%20%20%20%20%20%20%20%20%20%23%20most%20anti-correlated%0A%20%20%20%20%20%20%20%20near_i%20%3D%20int(np.argsort(_COS%5Bi%5D)%5B%3A%3A-1%5D%5B1%5D)%20%20%20%20%23%20most%20correlated%20(skip%20self)%0A%20%20%20%20%20%20%20%20P%2C%20O%2C%20Ncomp%20%3D%20_coords%5Bi%5D%2C%20_coords%5Bopp_i%5D%2C%20_coords%5Bnear_i%5D%0A%0A%20%20%20%20%20%20%20%20fig%20%3D%20_go.Figure()%0A%20%20%20%20%20%20%20%20fig.add_trace(_go.Scatter3d(%0A%20%20%20%20%20%20%20%20%20%20%20%20x%3D_CO.x%2C%20y%3D_CO.y%2C%20z%3D_CO.z%2C%20mode%3D%22markers%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20marker%3Ddict(size%3D3%2C%20color%3D_coords%5B%3A%2C%200%5D%2C%20colorscale%3D%22RdBu%22%2C%20opacity%3D0.5)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20text%3D_CO.Artist%2C%20hoverinfo%3D%22text%22%2C%20name%3D%22top-150%22))%0A%20%20%20%20%20%20%20%20_arrow(fig%2C%20P%2C%20O%2C%20%22%23ef553b%22%2C%20f%22%7Bartist%7D%20%E2%86%92%20true%20opposite%22)%0A%20%20%20%20%20%20%20%20pts%20%3D%20%7Bartist%3A%20(P%2C%20%22%2319d3f3%22)%2C%20_names%5Bnear_i%5D%3A%20(Ncomp%2C%20%22%2300cc96%22)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20_names%5Bopp_i%5D%3A%20(O%2C%20%22%23ef553b%22)%7D%0A%20%20%20%20%20%20%20%20fig.add_trace(_go.Scatter3d(%0A%20%20%20%20%20%20%20%20%20%20%20%20x%3D%5Bp%5B0%5D%5B0%5D%20for%20p%20in%20pts.values()%5D%2C%20y%3D%5Bp%5B0%5D%5B1%5D%20for%20p%20in%20pts.values()%5D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20z%3D%5Bp%5B0%5D%5B2%5D%20for%20p%20in%20pts.values()%5D%2C%20mode%3D%22markers%2Btext%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20marker%3Ddict(size%3D8%2C%20color%3D%5Bp%5B1%5D%20for%20p%20in%20pts.values()%5D)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20text%3Dlist(pts.keys())%2C%20textposition%3D%22top%20center%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20textfont%3Ddict(size%3D12%2C%20color%3D%22white%22)%2C%20name%3D%22anchors%22))%0A%0A%20%20%20%20%20%20%20%20key%20%3D%20np.array(%5BP%2C%20O%2C%20Ncomp%5D)%0A%20%20%20%20%20%20%20%20ctr%20%3D%20key.mean(axis%3D0)%0A%20%20%20%20%20%20%20%20half%20%3D%20max(float((key.max(0)%20-%20key.min(0)).max())%20%2F%202%2C%201e-6)%20*%20pad%0A%20%20%20%20%20%20%20%20rng%20%3D%20%5B%5Bctr%5Bk%5D%20-%20half%2C%20ctr%5Bk%5D%20%2B%20half%5D%20for%20k%20in%20range(3)%5D%0A%20%20%20%20%20%20%20%20fig.update_layout(%0A%20%20%20%20%20%20%20%20%20%20%20%20template%3D%22plotly_dark%22%2C%20height%3D760%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20title%3D(f%22%3Cb%3E%7Bartist%7D%3C%2Fb%3E's%20true%20opposite%3A%20%3Cb%3E%7B_names%5Bopp_i%5D%7D%3C%2Fb%3E%20%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20f%22(profile%20cos%20%7B_COS%5Bi%2C%20opp_i%5D%3A%2B.2f%7D)%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20f%22%3Cbr%3E%3Csup%3Esigned-PMI%2C%20top-150%20core%20%C2%B7%20nearest%20companion%20%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20f%22%7B_names%5Bnear_i%5D%7D%20(cos%20%7B_COS%5Bi%2C%20near_i%5D%3A%2B.2f%7D)%20%C2%B7%20%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20f%22colored%20by%20axis-1%20(the%20main%20co-listening%20spectrum)%3C%2Fsup%3E%22)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20scene%3Ddict(%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20xaxis%3Ddict(title%3D%22%22%2C%20range%3Drng%5B0%5D%2C%20showticklabels%3DFalse)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20yaxis%3Ddict(title%3D%22%22%2C%20range%3Drng%5B1%5D%2C%20showticklabels%3DFalse)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20zaxis%3Ddict(title%3D%22%22%2C%20range%3Drng%5B2%5D%2C%20showticklabels%3DFalse)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20aspectmode%3D%22cube%22)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20legend%3Ddict(orientation%3D%22h%22%2C%20y%3D-0.04)%2C%20margin%3Ddict(l%3D0%2C%20r%3D0%2C%20t%3D80%2C%20b%3D0))%0A%20%20%20%20%20%20%20%20return%20fig%0A%0A%20%20%20%20return%20core_opposite_artists%2C%20show_true_opposite%0A%0A%0A%40app.cell%0Adef%20_(core_opposite_artists%2C%20mo)%3A%0A%20%20%20%20to_dd%20%3D%20mo.ui.dropdown(%0A%20%20%20%20%20%20%20%20options%3Dcore_opposite_artists%2C%0A%20%20%20%20%20%20%20%20value%3D%22BONES%22%20if%20%22BONES%22%20in%20core_opposite_artists%20else%20core_opposite_artists%5B0%5D%2C%0A%20%20%20%20%20%20%20%20searchable%3DTrue%2C%0A%20%20%20%20)%0A%20%20%20%20to_zoom%20%3D%20mo.ui.slider(start%3D1.1%2C%20stop%3D4.0%2C%20step%3D0.1%2C%20value%3D1.6%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20label%3D%22crop%20(smaller%20%3D%20tighter)%22%2C%20show_value%3DTrue)%0A%20%20%20%20mo.md(f%22%22%22%0A%20%20%20%20**Find%20the%20*true*%20opposite%20of%3A**%20%20%7Bto_dd%7D%0A%0A%20%20%20%20%7Bto_zoom%7D%0A%0A%20%20%20%20*Cyan%20%3D%20the%20artist%2C%20green%20%3D%20nearest%20companion%20(%2Bcos)%2C%20red%20%3D%20true%20opposite%20(%E2%88%92cos).%0A%20%20%20%20Only%20the%20top-150%20core%20is%20shown%20%E2%80%94%20outside%20it%2C%20negative%20PMI%20is%20just%20noise.*%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%20to_dd%2C%20to_zoom%0A%0A%0A%40app.cell%0Adef%20_(show_true_opposite%2C%20to_dd%2C%20to_zoom)%3A%0A%20%20%20%20show_true_opposite(to_dd.value%2C%20pad%3Dto_zoom.value)%0A%20%20%20%20return%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%20The%20Brain%20in%203D%0A%0A%20%20%20%20A%20whole-library%20map%3A%20the%2050-D%20artist%20vectors%20squeezed%20to%20**3-D**%20so%20you%20can%0A%20%20%20%20orbit%20through%20the%20cluster%20structure.%0A%0A%20%20%20%20**Manifold%20learning%2C%20not%20PCA.**%20PCA%20(used%20earlier)%20is%20*linear*%20%E2%80%94%20great%20for%0A%20%20%20%20honest%20vector%20arithmetic%2C%20poor%20at%20untangling%20curved%20structure.%20**UMAP**%20(and%20its%0A%20%20%20%20cousin%20**t-SNE**)%20are%20*nonlinear*%3A%20they%20place%20each%20artist%20so%20that%20its%20**local%0A%20%20%20%20neighborhood**%20is%20preserved%2C%20pulling%20tight-knit%20clusters%20together.%20The%20trade-off%0A%20%20%20%20is%20that%20**global%20distances%20and%20the%20axes%20are%20meaningless**%20%E2%80%94%20only%20%22who%20sits%20near%0A%20%20%20%20whom%22%20is%20interpretable%2C%20which%20is%20why%20the%20axis%20ticks%20are%20hidden.%20The%20cell%20prefers%0A%20%20%20%20UMAP%20and%20silently%20falls%20back%20to%20t-SNE%20if%20%60umap-learn%60%20isn't%20installed.%0A%0A%20%20%20%20**What%20you're%20seeing.**%20Top-500%20artists%20by%20plays%20(one-hit%20wonders%20filtered%20out)%2C%0A%20%20%20%20sized%20by%20play%20count.%20Color%20is%20a%20**placeholder**%3A%20unsupervised%20*k-means%0A%20%20%20%20neighborhoods*%20on%20the%20raw%2050-D%20vectors%20%E2%80%94%20always%20available%2C%20looks%20like%20brain%0A%20%20%20%20regions%2C%20and%20is%20a%20single%20line%20(%60color%3D%22Neighborhood%22%60)%20to%20swap%20for%20genre%0A%20%20%20%20archetypes%2C%20a%20play-count%20gradient%2C%20or%20the%20PC1%20%22metal%20%E2%86%94%20hip-hop%22%20axis%20once%20the%0A%20%20%20%20story%20is%20decided.%20Hover%20for%20names%3B%20clusters%20that%20hang%20together%20are%20artists%20I%0A%20%20%20%20habitually%20play%20in%20the%20same%20sessions.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(%0A%20%20%20%20artist_vectors%2C%0A%20%20%20%20id_to_name%2C%0A%20%20%20%20n_artists%2C%0A%20%20%20%20pd%2C%0A%20%20%20%20sessionized_plays_df%2C%0A%20%20%20%20unique_artists%2C%0A)%3A%0A%20%20%20%20def%20_()%3A%0A%20%20%20%20%20%20%20%20import%20plotly.express%20as%20px%0A%0A%20%20%20%20%20%20%20%20%23%20---%203D%20embedding%3A%20prefer%20UMAP%2C%20fall%20back%20to%20t-SNE%20if%20not%20installed%20---%0A%20%20%20%20%20%20%20%20try%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20import%20umap%0A%20%20%20%20%20%20%20%20%20%20%20%20reducer%20%3D%20umap.UMAP(n_components%3D3%2C%20n_neighbors%3D15%2C%20min_dist%3D0.1%2C%20random_state%3D42)%0A%20%20%20%20%20%20%20%20%20%20%20%20method%20%3D%20%22UMAP%22%0A%20%20%20%20%20%20%20%20except%20ModuleNotFoundError%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20from%20sklearn.manifold%20import%20TSNE%0A%20%20%20%20%20%20%20%20%20%20%20%20reducer%20%3D%20TSNE(n_components%3D3%2C%20perplexity%3D30%2C%20random_state%3D42)%0A%20%20%20%20%20%20%20%20%20%20%20%20method%20%3D%20%22t-SNE%20(install%20umap-learn%20for%20UMAP)%22%0A%20%20%20%20%20%20%20%20print(f%22Projecting%2050D%20%E2%86%92%203D%20via%20%7Bmethod%7D%20...%22)%0A%20%20%20%20%20%20%20%20vectors_3d%20%3D%20reducer.fit_transform(artist_vectors)%0A%0A%20%20%20%20%20%20%20%20viz%20%3D%20pd.DataFrame(vectors_3d%2C%20columns%3D%5B%22X%22%2C%20%22Y%22%2C%20%22Z%22%5D)%0A%20%20%20%20%20%20%20%20viz%5B%22artist_id%22%5D%20%3D%20%5Bunique_artists%5Bi%5D%20for%20i%20in%20range(n_artists)%5D%0A%20%20%20%20%20%20%20%20viz%5B%22Artist%22%5D%20%3D%20viz%5B%22artist_id%22%5D.map(id_to_name)%0A%20%20%20%20%20%20%20%20counts%20%3D%20sessionized_plays_df%5B%22primary_artist_id%22%5D.value_counts()%0A%20%20%20%20%20%20%20%20viz%5B%22Plays%22%5D%20%3D%20viz%5B%22artist_id%22%5D.map(counts).fillna(1).astype(int)%0A%0A%20%20%20%20%20%20%20%20%23%20---%20PLACEHOLDER%20COLORING%3A%20k-means%20neighborhoods%20on%20the%2050D%20vectors%20---%0A%20%20%20%20%20%20%20%20%23%20Unsupervised%2C%20always%20available.%20Swap%20this%20block%20for%20archetype%2Fgenre%2Fetc.%0A%20%20%20%20%20%20%20%20from%20sklearn.cluster%20import%20KMeans%0A%20%20%20%20%20%20%20%20viz%5B%22Neighborhood%22%5D%20%3D%20KMeans(n_clusters%3D8%2C%20n_init%3D10%2C%20random_state%3D42)%20%5C%0A%20%20%20%20%20%20%20%20%20%20%20%20.fit_predict(artist_vectors).astype(str)%0A%0A%20%20%20%20%20%20%20%20top%20%3D%20viz.nlargest(500%2C%20%22Plays%22)%0A%0A%20%20%20%20%20%20%20%20fig%20%3D%20px.scatter_3d(%0A%20%20%20%20%20%20%20%20%20%20%20%20top%2C%20x%3D%22X%22%2C%20y%3D%22Y%22%2C%20z%3D%22Z%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20color%3D%22Neighborhood%22%2C%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%23%20%3C--%20the%20dimension%20to%20rethink%0A%20%20%20%20%20%20%20%20%20%20%20%20size%3D%22Plays%22%2C%20size_max%3D30%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20hover_name%3D%22Artist%22%2C%20hover_data%3D%7B%22Plays%22%3A%20True%2C%20%22X%22%3A%20False%2C%20%22Y%22%3A%20False%2C%20%22Z%22%3A%20False%7D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20opacity%3D0.8%2C%20template%3D%22plotly_dark%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20title%3D%22My%20Musical%20Brain%20in%203D%22%2C%0A%20%20%20%20%20%20%20%20)%0A%20%20%20%20%20%20%20%20fig.update_traces(marker%3Ddict(line%3Ddict(width%3D0)))%0A%20%20%20%20%20%20%20%20for%20ax%20in%20(%22xaxis%22%2C%20%22yaxis%22%2C%20%22zaxis%22)%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20fig.update_layout(**%7Bf%22scene_%7Bax%7D%22%3A%20dict(showticklabels%3DFalse%2C%20title%3D%22%22)%7D)%0A%20%20%20%20%20%20%20%20fig.update_layout(height%3D800%2C%20legend_title_text%3D%22Neighborhood%22)%0A%20%20%20%20%20%20%20%20return%20fig%0A%0A%20%20%20%20_()%0A%20%20%20%20return%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%20Act%20III%20%E2%80%94%20The%20Attention%20Budget%0A%0A%20%20%20%20Acts%20I%20and%20II%20both%20asked%20*what*%20I%20listen%20to.%20This%20act%20asks%20something%20the%20data%0A%20%20%20%20was%20never%20supposed%20to%20answer%3A%20**how%20much%20of%20each%20track%20do%20I%20actually%20sit%0A%20%20%20%20through%3F**%0A%0A%20%20%20%20Spotify%20does%20not%20expose%20a%20skip.%20There%20is%20no%20%60skipped%60%20column%2C%20no%20completion%0A%20%20%20%20percentage%2C%20nothing%20%E2%80%94%20the%20recently-played%20feed%20gives%20you%20a%20track%20and%20a%0A%20%20%20%20timestamp.%20But%20the%20timestamp%20is%20the%20*start*%20of%20the%20play%2C%20and%20that%20turns%20out%20to%0A%20%20%20%20be%20enough.%20If%20%60played_at%60%20marks%20when%20a%20track%20began%2C%20then%20the%20gap%20to%20the%20**next**%0A%20%20%20%20play%20*is*%20how%20long%20I%20listened%2C%20and%20a%20gap%20shorter%20than%20the%20track's%20own%20duration%0A%20%20%20%20is%20a%20skip.%0A%0A%20%20%20%20That's%20a%20load-bearing%20assumption%2C%20so%20it%20gets%20checked%20rather%20than%20believed%3A%0A%20%20%20%20across%2045%2C253%20in-session%20transitions%20the%20median%20%60gap%20%2F%20duration%60%20is%20**0.998**.%0A%20%20%20%20Tracks%20that%20get%20played%20end-to-end%20land%20exactly%20on%201.0.%20The%20clock%20is%20telling%20the%0A%20%20%20%20truth%2C%20and%20a%20field%20Spotify%20withholds%20is%20recoverable%20with%20a%20%60LEAD()%60%20window%20and%0A%20%20%20%20a%20subtraction.%0A%0A%20%20%20%20**Definitions%20used%20below.**%20A%20*session*%20is%20a%20run%20of%20plays%20with%20no%20gap%20over%2030%0A%20%20%20%20minutes%20(same%20threshold%20as%20Act%20I).%20A%20play%20is%20**abandoned**%20if%20the%20next%20play%0A%20%20%20%20starts%20more%20than%205%20seconds%20before%20this%20track%20would%20have%20ended%20%E2%80%94%20the%205s%20is%20slack%0A%20%20%20%20for%20crossfade%20and%20polling%20jitter.%20Plays%20that%20end%20a%20session%20are%20handled%0A%20%20%20%20separately%3B%20they're%20the%20interesting%20statistical%20problem%2C%20and%20they%20get%20their%20own%0A%20%20%20%20section.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(WES_ID%2C%20engine%2C%20np%2C%20pd%2C%20text)%3A%0A%20%20%20%20%23%20Constants%20for%20Act%20III.%20Seconds%20throughout%20(Act%20I's%20SESSION_THRESHOLD%20is%20a%0A%20%20%20%20%23%20Timedelta%3B%20here%20the%20arithmetic%20is%20all%20float%20seconds).%0A%20%20%20%20ATTN_GAP%20%3D%201800.0%20%20%20%20%20%23%20session%20boundary%3A%2030%20min%0A%20%20%20%20ATTN_FLOOR%20%3D%2030.0%20%20%20%20%20%23%20Spotify%20only%20logs%20plays%20past%20~30s%0A%20%20%20%20ATTN_TOL%20%3D%205.0%20%20%20%20%20%20%20%20%23%20crossfade%20%2F%20polling%20slack%20before%20calling%20it%20a%20skip%0A%0A%20%20%20%20%23%20Palette%3A%20categorical%20slots%201-2%20%2B%20a%20neutral.%20Validated%20for%20CVD%20separation.%0A%20%20%20%20ATTN_BLUE%2C%20ATTN_ORANGE%2C%20ATTN_GRAY%20%3D%20%22%232a78d6%22%2C%20%22%23eb6834%22%2C%20%22%23c9c8c3%22%0A%20%20%20%20ATTN_INK%20%3D%20%22%2352514e%22%0A%0A%20%20%20%20attn_plays%20%3D%20pd.read_sql(text(%22%22%22%0A%20%20%20%20%20%20%20%20SELECT%20played_at%2C%20duration_ms%2C%20artist_id%2C%20artist_name%2C%20track_name%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20played_hour_local%0A%20%20%20%20%20%20%20%20FROM%20mart.fact_plays_enriched%0A%20%20%20%20%20%20%20%20WHERE%20owner_id%20%3D%20%3Ao%20AND%20duration_ms%20%3E%200%0A%20%20%20%20%20%20%20%20ORDER%20BY%20played_at%0A%20%20%20%20%22%22%22)%2C%20engine%2C%20params%3D%7B%22o%22%3A%20WES_ID%7D)%0A%0A%20%20%20%20attn_plays%5B%22played_at%22%5D%20%3D%20pd.to_datetime(attn_plays%5B%22played_at%22%5D%2C%20utc%3DTrue)%0A%20%20%20%20attn_plays%5B%22dur%22%5D%20%3D%20attn_plays%5B%22duration_ms%22%5D%20%2F%201000.0%0A%0A%20%20%20%20%23%20The%20whole%20trick%2C%20in%20one%20line%3A%20seconds%20until%20the%20next%20play%20began.%0A%20%20%20%20%23%20NOTE%3A%20the%20SQL%20above%20filters%20to%20a%20single%20owner.%20If%20you%20ever%20widen%20it%2C%20this%0A%20%20%20%20%23%20MUST%20become%20groupby(%22owner_id%22)%5B%22played_at%22%5D.diff()%20or%20you%20will%20splice%20two%0A%20%20%20%20%23%20people's%20timelines%20together%20at%20the%20seam.%0A%20%20%20%20attn_plays%5B%22gap%22%5D%20%3D%20(attn_plays%5B%22played_at%22%5D.shift(-1)%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20.sub(attn_plays%5B%22played_at%22%5D).dt.total_seconds())%0A%0A%20%20%20%20attn_plays%5B%22in_session%22%5D%20%3D%20attn_plays%5B%22gap%22%5D%20%3C%20ATTN_GAP%0A%20%20%20%20attn_plays%5B%22skipped%22%5D%20%3D%20(attn_plays%5B%22in_session%22%5D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%26%20(attn_plays%5B%22gap%22%5D%20%3C%20attn_plays%5B%22dur%22%5D%20-%20ATTN_TOL))%0A%20%20%20%20attn_plays%5B%22listened%22%5D%20%3D%20np.minimum(attn_plays%5B%22gap%22%5D%2C%20attn_plays%5B%22dur%22%5D)%0A%0A%20%20%20%20_ratio%20%3D%20(attn_plays%5B%22gap%22%5D%20%2F%20attn_plays%5B%22dur%22%5D)%5Battn_plays%5B%22in_session%22%5D%5D%0A%20%20%20%20print(%22---%20does%20played_at%20mark%20the%20START%20of%20the%20play%3F%20---%22)%0A%20%20%20%20print(f%22median%20gap%2Fduration%20%3D%20%7B_ratio.median()%3A.3f%7D%20%20%20(1.000%20%3D%20yes)%22)%0A%20%20%20%20print()%0A%20%20%20%20print(f%22in-session%20plays%20%3A%20%7Battn_plays%5B'in_session'%5D.sum()%3A%2C%7D%22)%0A%20%20%20%20print(f%22session-final%20%20%20%20%3A%20%7B(~attn_plays%5B'in_session'%5D).sum()%3A%2C%7D%20%22%0A%20%20%20%20%20%20%20%20%20%20f%22(%7B(~attn_plays%5B'in_session'%5D).mean()%3A.1%25%7D)%20%20%3C-%20censored%2C%20see%20below%22)%0A%20%20%20%20print(f%22ABANDONMENT%20RATE%20%3A%20%22%0A%20%20%20%20%20%20%20%20%20%20f%22%7Battn_plays.loc%5Battn_plays%5B'in_session'%5D%2C%20'skipped'%5D.mean()%3A.1%25%7D%22)%0A%20%20%20%20print()%0A%20%20%20%20print(%22---%20contamination%20check%3A%20gaps%20that%20look%20like%20the%20poller%20missed%20a%20play%20---%22)%0A%20%20%20%20print(f%22gap%20~%3D%202x%20duration%20%3A%20%7B_ratio.between(1.8%2C%202.2).mean()%3A.1%25%7D%22)%0A%20%20%20%20print(f%22gap%20~%3D%203x%20duration%20%3A%20%7B_ratio.between(2.8%2C%203.2).mean()%3A.1%25%7D%22)%0A%20%20%20%20print(%22(a%20missed%20play%20inflates%20the%20gap%2C%20so%20it%20reads%20as%20'fully%20listened'%20%E2%80%94%20%22%0A%20%20%20%20%20%20%20%20%20%20%22the%20abandonment%20rate%20above%20is%20an%20UNDERESTIMATE)%22)%0A%20%20%20%20return%20ATTN_BLUE%2C%20ATTN_FLOOR%2C%20ATTN_GRAY%2C%20ATTN_INK%2C%20ATTN_ORANGE%2C%20attn_plays%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%23%20It%20isn't%20a%20skip%20rate.%20It's%20a%20budget.%0A%0A%20%20%20%2047%25%20of%20my%20in-session%20plays%20get%20abandoned%2C%20which%20sounds%20like%20a%20statement%20about%0A%20%20%20%20how%20picky%20I%20am.%20It%20isn't.%20Bucket%20the%20plays%20by%20track%20length%20and%20the%20story%0A%20%20%20%20inverts%3A%20what%20stays%20constant%20isn't%20the%20*fraction*%20I%20finish%2C%20it's%20the%20number%20of%0A%20%20%20%20**seconds**%20I'm%20willing%20to%20spend.%0A%0A%20%20%20%20Below%2C%20the%20grey%20bar%20is%20how%20long%20the%20track%20is%3B%20the%20blue%20bar%20is%20how%20much%20of%20it%20I%0A%20%20%20%20actually%20heard.%20Up%20to%20about%20four%20minutes%20the%20two%20track%20each%20other.%20Past%20four%0A%20%20%20%20minutes%20the%20grey%20bar%20keeps%20growing%20and%20the%20blue%20one%20simply%20stops%20%E2%80%94%20flat%20at%0A%20%20%20%20~226%20seconds%20whether%20the%20track%20is%20five%20minutes%20or%20ten.%0A%0A%20%20%20%20A%20ten-minute%20track%20doesn't%20get%20me%20to%20listen%20for%20ten%20minutes.%20It%20gets%20me%20to%0A%20%20%20%20listen%20for%20the%20same%203%C2%BE%20minutes%20and%20then%20leave.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(ATTN_BLUE%2C%20ATTN_GRAY%2C%20ATTN_ORANGE%2C%20attn_plays%2C%20np%2C%20pd%2C%20plt)%3A%0A%20%20%20%20_edges%20%3D%20%5B60%2C%20120%2C%20180%2C%20240%2C%20300%2C%20360%2C%20420%2C%20600%5D%0A%20%20%20%20_labels%20%3D%20%5B%221%E2%80%932%20min%22%2C%20%222%E2%80%933%22%2C%20%223%E2%80%934%22%2C%20%224%E2%80%935%22%2C%20%225%E2%80%936%22%2C%20%226%E2%80%937%22%2C%20%227%E2%80%9310%22%5D%0A%0A%20%20%20%20_d%20%3D%20attn_plays%5Battn_plays%5B%22in_session%22%5D%5D.copy()%0A%20%20%20%20_d%5B%22bucket%22%5D%20%3D%20pd.cut(_d%5B%22dur%22%5D%2C%20_edges%2C%20labels%3D_labels)%0A%20%20%20%20attn_budget%20%3D%20_d.groupby(%22bucket%22%2C%20observed%3DTrue).agg(%0A%20%20%20%20%20%20%20%20listened%3D(%22listened%22%2C%20%22median%22)%2C%0A%20%20%20%20%20%20%20%20length%3D(%22dur%22%2C%20%22median%22)%2C%0A%20%20%20%20%20%20%20%20abandoned%3D(%22skipped%22%2C%20%22mean%22)%2C%0A%20%20%20%20%20%20%20%20n%3D(%22dur%22%2C%20%22size%22)%2C%0A%20%20%20%20)%0A%20%20%20%20attn_budget%5B%22abandoned%22%5D%20%3D%20(attn_budget%5B%22abandoned%22%5D%20*%20100).round(1)%0A%0A%20%20%20%20%23%20the%20plateau%3A%20median%20listened%20across%20everything%20over%204%20minutes%0A%20%20%20%20_plateau%20%3D%20_d.loc%5B_d%5B%22dur%22%5D%20%3E%20240%2C%20%22listened%22%5D.median()%0A%0A%20%20%20%20_fig%2C%20_ax%20%3D%20plt.subplots(figsize%3D(11%2C%205.4))%0A%20%20%20%20_y%20%3D%20np.arange(len(attn_budget))%0A%20%20%20%20_ax.barh(_y%2C%20attn_budget%5B%22length%22%5D%2C%20height%3D0.62%2C%20color%3DATTN_GRAY%2C%20zorder%3D2%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20label%3D%22track%20length%20(median)%22)%0A%20%20%20%20_ax.barh(_y%2C%20attn_budget%5B%22listened%22%5D%2C%20height%3D0.34%2C%20color%3DATTN_BLUE%2C%20zorder%3D3%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20label%3D%22actually%20listened%20(median)%22)%0A%0A%20%20%20%20_ax.axvline(_plateau%2C%20color%3DATTN_ORANGE%2C%20lw%3D2%2C%20ls%3D%22--%22%2C%20zorder%3D4)%0A%20%20%20%20_ax.text(_plateau%20%2B%208%2C%20-0.72%2C%20f%22the%20plateau%20%E2%80%94%20%7B_plateau%3A.0f%7Ds%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20color%3DATTN_ORANGE%2C%20fontsize%3D10.5%2C%20va%3D%22center%22%2C%20fontweight%3D%22bold%22)%0A%0A%20%20%20%20for%20_i%2C%20(_lis%2C%20_len)%20in%20enumerate(zip(attn_budget%5B%22listened%22%5D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20attn_budget%5B%22length%22%5D))%3A%0A%20%20%20%20%20%20%20%20_ax.text(_lis%20-%208%2C%20_i%2C%20f%22%7B_lis%3A.0f%7Ds%22%2C%20va%3D%22center%22%2C%20ha%3D%22right%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20color%3D%22white%22%2C%20fontsize%3D9.5%2C%20fontweight%3D%22bold%22%2C%20zorder%3D5)%0A%20%20%20%20%20%20%20%20_ax.text(_len%20%2B%208%2C%20_i%2C%20f%22%7B_len%3A.0f%7Ds%22%2C%20va%3D%22center%22%2C%20ha%3D%22left%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20color%3D%22%236b6a66%22%2C%20fontsize%3D9)%0A%0A%20%20%20%20_ax.set_yticks(_y)%3B%20_ax.set_yticklabels(attn_budget.index)%0A%20%20%20%20_ax.set_ylim(len(attn_budget)%20-%200.4%2C%20-1.1)%0A%20%20%20%20_ax.set_xlabel(%22seconds%22)%0A%20%20%20%20_ax.set_title(%22Longer%20tracks%20don't%20buy%20more%20listening%20%E2%80%94%20they%20just%20get%20abandoned%5Cn%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22median%20seconds%20listened%20vs.%20median%20track%20length%2C%20by%20track%20length%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20fontsize%3D13%2C%20loc%3D%22left%22%2C%20pad%3D34)%0A%20%20%20%20_ax.legend(loc%3D%22lower%20left%22%2C%20bbox_to_anchor%3D(0%2C%201.005)%2C%20ncol%3D2%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20frameon%3DFalse%2C%20fontsize%3D9.5)%0A%20%20%20%20_ax.grid(axis%3D%22x%22%2C%20alpha%3D0.25%2C%20zorder%3D0)%3B%20_ax.set_axisbelow(True)%0A%20%20%20%20for%20_s%20in%20(%22top%22%2C%20%22right%22%2C%20%22left%22)%3A%0A%20%20%20%20%20%20%20%20_ax.spines%5B_s%5D.set_visible(False)%0A%20%20%20%20plt.tight_layout()%3B%20plt.show()%0A%0A%20%20%20%20print(attn_budget.to_string())%0A%20%20%20%20print(f%22%5Cnplateau%20(all%20tracks%20%3E%20240s)%3A%20%7B_plateau%3A.0f%7Ds%20%20%20%22%0A%20%20%20%20%20%20%20%20%20%20f%22n%20%3D%20%7B(_d%5B'dur'%5D%20%3E%20240).sum()%3A%2C%7D%22)%0A%20%20%20%20print(f%22%5Cn%7B''%3A4%7Dnote%20the%20dip%20at%203%E2%80%934%20min%3A%20abandonment%20goes%2046.6%25%20-%3E%2044.1%25%20-%3E%2050.2%25%22)%0A%20%20%20%20print(f%22%7B''%3A4%7Dacross%20150%2F180%2F210%2F240s.%20Not%20monotonic%2C%20and%20not%20noise%20(95%25%20CIs%22)%0A%20%20%20%20print(f%22%7B''%3A4%7D%2B%2F-1.1%20to%201.3pp).%20Tracks%20in%20the%20classic%203.5-minute%20pop%20slot%20fit%22)%0A%20%20%20%20print(f%22%7B''%3A4%7DINSIDE%20the%20budget%20with%20room%20to%20spare%2C%20so%20they%20get%20finished%20more%22)%0A%20%20%20%20print(f%22%7B''%3A4%7Doften%20than%20slightly%20shorter%20ones.%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%23%20The%20honest%20version%3A%20survival%20analysis%0A%0A%20%20%20%20%22Median%20seconds%20listened%22%20is%20a%20descriptive%20statistic%20hiding%20a%20real%20problem.%0A%20%20%20%20**10.4%25%20of%20plays%20are%20the%20last%20one%20in%20their%20session**%20%E2%80%94%20I%20put%20something%20on%20and%0A%20%20%20%20walked%20away.%20For%20those%2C%20the%20next%20timestamp%20is%20hours%20later%20and%20tells%20us%20nothing%0A%20%20%20%20about%20when%20I%20actually%20stopped%20listening.%20Throwing%20them%20out%20is%20not%20neutral%3A%0A%20%20%20%20session-final%20plays%20are%20*precisely*%20the%20ones%20where%20I%20wandered%20off%20mid-track%2C%20so%0A%20%20%20%20dropping%20them%20biases%20the%20picture%20toward%20patience.%0A%0A%20%20%20%20This%20is%20textbook%20**right-censoring**%2C%20and%20it's%20what%20survival%20analysis%20is%20for.%0A%20%20%20%20Two%20different%20kinds%20of%20censoring%20are%20in%20play%20here%3A%0A%0A%20%20%20%20-%20**Completed%20tracks**%20are%20censored%20at%20their%20own%20duration.%20If%20a%203-minute%20song%0A%20%20%20%20%20%20plays%20to%20the%20end%2C%20I%20never%20abandoned%20it%20%E2%80%94%20I%20just%20ran%20out%20of%20song.%20It%0A%20%20%20%20%20%20contributes%20%22survived%20to%20180s%22%20and%20then%20leaves%20the%20risk%20set.%20This%20is%20ordinary%0A%20%20%20%20%20%20administrative%20censoring%20and%20**Kaplan%E2%80%93Meier%20handles%20it%20correctly**%2C%20which%20is%0A%20%20%20%20%20%20exactly%20why%20the%20naive%20%22average%20%25%20listened%22%20is%20the%20wrong%20tool.%0A%20%20%20%20-%20**Session-final%20plays**%20are%20censored%20*informatively*%20%E2%80%94%20the%20reason%20the%0A%20%20%20%20%20%20observation%20ends%20is%20correlated%20with%20the%20outcome%20we're%20measuring.%20There%20is%20no%0A%20%20%20%20%20%20unbiased%20fix%20for%20that%2C%20so%20rather%20than%20pick%20an%20assumption%20and%20hope%2C%20the%20curve%0A%20%20%20%20%20%20is%20computed%20under%20all%20three%20and%20reported%20as%20a%20**range**%3A%0A%0A%20%20%20%20%7C%20scenario%20%7C%20session-final%20plays%20treated%20as%20%7C%0A%20%20%20%20%7C---%7C---%7C%0A%20%20%20%20%7C%20%60observed%60%20%7C%20dropped%20(the%20naive%20baseline)%20%7C%0A%20%20%20%20%7C%20%60optimistic%60%20%7C%20listened%20to%20the%20end%20%E2%80%94%20censored%20at%20full%20duration%20%7C%0A%20%20%20%20%7C%20%60pessimistic%60%20%7C%20walked%20away%20immediately%20%E2%80%94%20abandoned%20at%20the%2030s%20floor%20%7C%0A%0A%20%20%20%20If%20the%20headline%20number%20is%20stable%20across%20that%20range%2C%20it%20isn't%20an%20artifact%20of%20the%0A%20%20%20%20assumption.%20Kaplan%E2%80%93Meier%20itself%20is%20about%20ten%20lines%20of%20numpy%20%E2%80%94%0A%20%20%20%20%24%5Chat%20S(t)%20%3D%20%5Cprod_%7Bt_i%20%5Cle%20t%7D%5Cleft(1%20-%20%5Cfrac%7Bd_i%7D%7Bn_i%7D%5Cright)%24%2C%20where%20%24d_i%24%20is%0A%20%20%20%20abandonments%20at%20time%20%24t_i%24%20and%20%24n_i%24%20is%20how%20many%20plays%20were%20still%20going%20%E2%80%94%20so%0A%20%20%20%20there's%20no%20need%20for%20a%20%60lifelines%60%20dependency.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(ATTN_BLUE%2C%20ATTN_FLOOR%2C%20ATTN_ORANGE%2C%20attn_plays%2C%20np%2C%20plt)%3A%0A%20%20%20%20def%20attn_km(time%2C%20event)%3A%0A%20%20%20%20%20%20%20%20%22%22%22Kaplan-Meier%20survivor%20function.%20event%3DTrue%20means%20'abandoned%20here'%3B%0A%20%20%20%20%20%20%20%20event%3DFalse%20means%20right-censored%20at%20that%20time.%22%22%22%0A%20%20%20%20%20%20%20%20time%20%3D%20np.asarray(time%2C%20float)%0A%20%20%20%20%20%20%20%20event%20%3D%20np.asarray(event%2C%20bool)%0A%20%20%20%20%20%20%20%20_o%20%3D%20np.argsort(time%2C%20kind%3D%22mergesort%22)%0A%20%20%20%20%20%20%20%20time%2C%20event%20%3D%20time%5B_o%5D%2C%20event%5B_o%5D%0A%20%20%20%20%20%20%20%20_uniq%2C%20_first%20%3D%20np.unique(time%2C%20return_index%3DTrue)%0A%20%20%20%20%20%20%20%20_d%20%3D%20np.add.reduceat(event.astype(int)%2C%20_first)%20%20%20%20%20%20%20%20%20%20%23%20abandonments%0A%20%20%20%20%20%20%20%20_n%20%3D%20(len(time)%20-%20np.arange(len(time)))%5B_first%5D%20%20%20%20%20%20%20%20%20%20%23%20still%20at%20risk%0A%20%20%20%20%20%20%20%20_S%20%3D%20np.cumprod(1.0%20-%20_d%20%2F%20_n)%0A%20%20%20%20%20%20%20%20_keep%20%3D%20_d%20%3E%200%0A%20%20%20%20%20%20%20%20return%20_uniq%5B_keep%5D%2C%20_S%5B_keep%5D%0A%0A%20%20%20%20def%20attn_rmst(t%2C%20S%2C%20horizon)%3A%0A%20%20%20%20%20%20%20%20%22%22%22Restricted%20mean%20survival%20time%20%3D%20expected%20seconds%20listened%20for%20a%20track%0A%20%20%20%20%20%20%20%20of%20length%20%60horizon%60.%20Area%20under%20S(t)%20on%20%5B0%2C%20horizon%5D.%22%22%22%0A%20%20%20%20%20%20%20%20_m%20%3D%20t%20%3C%20horizon%0A%20%20%20%20%20%20%20%20_tt%20%3D%20np.concatenate((%5B0.0%5D%2C%20t%5B_m%5D%2C%20%5Bhorizon%5D))%0A%20%20%20%20%20%20%20%20_SS%20%3D%20np.concatenate((%5B1.0%5D%2C%20S%5B_m%5D))%0A%20%20%20%20%20%20%20%20return%20float(np.sum(np.diff(_tt)%20*%20_SS))%0A%0A%20%20%20%20def%20attn_case(scenario)%3A%0A%20%20%20%20%20%20%20%20%22%22%22(time%2C%20event)%20arrays%20under%20one%20censoring%20assumption.%22%22%22%0A%20%20%20%20%20%20%20%20_dur%20%3D%20attn_plays%5B%22dur%22%5D.values%0A%20%20%20%20%20%20%20%20_gap%20%3D%20attn_plays%5B%22gap%22%5D.values%0A%20%20%20%20%20%20%20%20_fin%20%3D%20~attn_plays%5B%22in_session%22%5D.values%0A%20%20%20%20%20%20%20%20_skip%20%3D%20attn_plays%5B%22skipped%22%5D.values%0A%0A%20%20%20%20%20%20%20%20_time%2C%20_event%20%3D%20_dur.copy()%2C%20np.zeros(len(_dur)%2C%20bool)%0A%20%20%20%20%20%20%20%20_time%5B_skip%5D%20%3D%20np.maximum(_gap%5B_skip%5D%2C%20ATTN_FLOOR)%20%20%20%23%20abandoned%20here%0A%20%20%20%20%20%20%20%20_event%5B_skip%5D%20%3D%20True%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%23%20everything%20else%20is%0A%20%20%20%20%20%20%20%20if%20scenario%20%3D%3D%20%22observed%22%3A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%23%20censored%20at%20its%20dur%0A%20%20%20%20%20%20%20%20%20%20%20%20return%20_time%5B~_fin%5D%2C%20_event%5B~_fin%5D%0A%20%20%20%20%20%20%20%20if%20scenario%20%3D%3D%20%22optimistic%22%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20return%20_time%2C%20_event%0A%20%20%20%20%20%20%20%20if%20scenario%20%3D%3D%20%22pessimistic%22%3A%0A%20%20%20%20%20%20%20%20%20%20%20%20_time%5B_fin%5D%20%3D%20np.minimum(_dur%5B_fin%5D%2C%20ATTN_FLOOR)%0A%20%20%20%20%20%20%20%20%20%20%20%20_event%5B_fin%5D%20%3D%20True%0A%20%20%20%20%20%20%20%20%20%20%20%20return%20_time%2C%20_event%0A%20%20%20%20%20%20%20%20raise%20ValueError(scenario)%0A%0A%20%20%20%20attn_curves%20%3D%20%7B_s%3A%20attn_km(*attn_case(_s))%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20for%20_s%20in%20(%22observed%22%2C%20%22optimistic%22%2C%20%22pessimistic%22)%7D%0A%20%20%20%20attn_median%20%3D%20%7B_s%3A%20_t%5Bnp.argmax(_S%20%3C%3D%200.5)%5D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20for%20_s%2C%20(_t%2C%20_S)%20in%20attn_curves.items()%7D%0A%0A%20%20%20%20_grid%20%3D%20np.arange(0%2C%20421)%0A%20%20%20%20_S_at%20%3D%20%7B_s%3A%20np.interp(_grid%2C%20_t%2C%20_Sv%2C%20left%3D1.0)%0A%20%20%20%20%20%20%20%20%20%20%20%20%20for%20_s%2C%20(_t%2C%20_Sv)%20in%20attn_curves.items()%7D%0A%0A%20%20%20%20_fig%2C%20_ax%20%3D%20plt.subplots(figsize%3D(11%2C%205.6))%0A%20%20%20%20_ax.fill_between(_grid%2C%20_S_at%5B%22pessimistic%22%5D%2C%20_S_at%5B%22optimistic%22%5D%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20color%3DATTN_BLUE%2C%20alpha%3D0.16%2C%20lw%3D0%2C%20zorder%3D2%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20label%3D%22range%20over%20censoring%20assumptions%22)%0A%20%20%20%20_ax.plot(_grid%2C%20_S_at%5B%22observed%22%5D%2C%20color%3DATTN_BLUE%2C%20lw%3D2.2%2C%20zorder%3D3%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20label%3D%22observed%20transitions%22)%0A%0A%20%20%20%20_m%20%3D%20attn_median%5B%22observed%22%5D%0A%20%20%20%20_ax.plot(%5B_m%2C%20_m%5D%2C%20%5B0%2C%200.5%5D%2C%20color%3DATTN_ORANGE%2C%20lw%3D2%2C%20ls%3D%22--%22%2C%20zorder%3D4)%0A%20%20%20%20_ax.plot(%5B0%2C%20_m%5D%2C%20%5B0.5%2C%200.5%5D%2C%20color%3DATTN_ORANGE%2C%20lw%3D2%2C%20ls%3D%22--%22%2C%20zorder%3D4)%0A%20%20%20%20_ax.plot(%5B_m%5D%2C%20%5B0.5%5D%2C%20%22o%22%2C%20ms%3D9%2C%20color%3DATTN_ORANGE%2C%20zorder%3D5)%0A%20%20%20%20_ax.annotate(f%22half%20of%20plays%20are%20gone%5Cnby%20%7B_m%3A.0f%7Ds%20%20%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20f%22(%7B_m%20%2F%2F%2060%3A.0f%7D%3A%7B_m%20%25%2060%3A02.0f%7D)%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20(_m%2C%200.5)%2C%20xytext%3D(_m%20%2B%2022%2C%200.63)%2C%20color%3DATTN_ORANGE%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20fontsize%3D10.5%2C%20fontweight%3D%22bold%22)%0A%0A%20%20%20%20_ax.axvline(ATTN_FLOOR%2C%20color%3D%22%238a8983%22%2C%20lw%3D1%2C%20ls%3D%22%3A%22%2C%20zorder%3D1)%0A%20%20%20%20_ax.text(ATTN_FLOOR%20%2B%205%2C%200.055%2C%20%22Spotify's%2030s%20floor%20%E2%80%94%5Cnfaster%20bails%20are%20invisible%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20fontsize%3D8.5%2C%20color%3D%22%236b6a66%22%2C%20va%3D%22bottom%22)%0A%0A%20%20%20%20_ax.set_xlim(0%2C%20420)%3B%20_ax.set_ylim(0%2C%201.02)%0A%20%20%20%20_ax.set_xlabel(%22seconds%20into%20the%20track%22)%0A%20%20%20%20_ax.set_ylabel(%22P(still%20listening)%22)%0A%20%20%20%20_ax.set_title(%22How%20long%20I%20last%2C%20once%20I've%20committed%2030%20seconds%5Cn%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22Kaplan%E2%80%93Meier%20survival%2C%20completed%20tracks%20censored%20at%20their%20own%20length%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20fontsize%3D13%2C%20loc%3D%22left%22)%0A%20%20%20%20_ax.legend(loc%3D%22upper%20right%22%2C%20frameon%3DFalse)%0A%20%20%20%20_ax.grid(alpha%3D0.25)%3B%20_ax.set_axisbelow(True)%0A%20%20%20%20for%20_sp%20in%20(%22top%22%2C%20%22right%22)%3A%0A%20%20%20%20%20%20%20%20_ax.spines%5B_sp%5D.set_visible(False)%0A%20%20%20%20plt.tight_layout()%3B%20plt.show()%0A%0A%20%20%20%20print(%22MEDIAN%20SURVIVAL%20(seconds%20before%20half%20of%20plays%20are%20abandoned)%22)%0A%20%20%20%20for%20_s%2C%20_v%20in%20attn_median.items()%3A%0A%20%20%20%20%20%20%20%20print(f%22%20%20%7B_s%3A%3C12%7D%20%7B_v%3A%3E5.0f%7Ds%22)%0A%20%20%20%20print(f%22%5Cn%20%20-%3E%20spread%20across%20every%20censoring%20assumption%3A%20%22%0A%20%20%20%20%20%20%20%20%20%20f%22%7Bmax(attn_median.values())%20-%20min(attn_median.values())%3A.0f%7Ds.%20%22%0A%20%20%20%20%20%20%20%20%20%20f%22The%20headline%20is%20robust.%22)%0A%0A%20%20%20%20print(%22%5CnRESTRICTED%20MEAN%20SURVIVAL%20%E2%80%94%20expected%20seconds%20listened%2C%20by%20track%20length%22)%0A%20%20%20%20print(f%22%7B'track%20len'%3A%3E10%7D%20%7C%20%22%20%2B%20%22%20%7C%20%22.join(f%22%7B_s%3A%3E12%7D%22%20for%20_s%20in%20attn_curves))%0A%20%20%20%20for%20_h%20in%20(120%2C%20180%2C%20240%2C%20300%2C%20420%2C%20600%2C%20900)%3A%0A%20%20%20%20%20%20%20%20print(f%22%7B_h%3A%3E8.0f%7Ds%20%20%7C%20%22%20%2B%20%22%20%7C%20%22.join(%0A%20%20%20%20%20%20%20%20%20%20%20%20f%22%7Battn_rmst(*attn_curves%5B_s%5D%2C%20_h)%3A%3E11.0f%7Ds%22%20for%20_s%20in%20attn_curves))%0A%20%20%20%20print(%22%5Cn%20%20-%3E%20saturates.%20A%2015-minute%20track%20buys%20~220-320s%20of%20attention%2C%20and%22)%0A%20%20%20%20print(%22%20%20%20%20%20that%20ceiling%20is%20the%20same%20one%20the%20bar%20chart%20above%20found.%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%23%20The%20genre%20mix%20churned.%20The%20budget%20didn't.%0A%0A%20%20%20%20Act%20I%20spent%20a%20lot%20of%20effort%20showing%20that%20my%20taste%20is%20*legible%20over%20time*%20%E2%80%94%20the%0A%20%20%20%20NMF%20archetypes%20turn%20on%20and%20off%20so%20distinctly%20that%20a%20random%20forest%20can%20date%20a%0A%20%20%20%20listening%20window%20to%20within%20a%20couple%20of%20years%20from%20genre-mix%20alone.%20Cloud%20rap%0A%20%20%20%20displaced%20post-grunge%3B%20metalcore%20came%20and%20went.%0A%0A%20%20%20%20Underneath%20all%20of%20that%2C%20the%20attention%20budget%20doesn't%20move.%20Median%20seconds%0A%20%20%20%20listened%20on%20long%20tracks%2C%20by%20year%3A%20**234%2C%20233%2C%20232%2C%20227%2C%20223%2C%20216%2C%20230%2C%20216**.%0A%20%20%20%20Eight%20years%2C%20an%208%25%20range%2C%20with%20a%20mild%20downward%20drift%20that's%20the%20only%20thing%0A%20%20%20%20resembling%20a%20trend.%20Same%20story%20by%20hour%20of%20day%20%E2%80%94%20216%20to%20232%20seconds%20from%207am%20to%0A%20%20%20%2011pm%2C%20no%20shape%20to%20it%20at%20all.%0A%0A%20%20%20%20Which%20is%20the%20genuinely%20interesting%20result.%20*What*%20I%20put%20on%20is%20a%20decade%20of%0A%20%20%20%20change.%20*How%20long%20I'll%20sit%20still%20for%20it*%20looks%20like%20a%20fixed%20property%20of%20the%0A%20%20%20%20listener%2C%20not%20the%20music.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(ATTN_BLUE%2C%20ATTN_INK%2C%20attn_plays%2C%20plt)%3A%0A%20%20%20%20_long%20%3D%20attn_plays%5Battn_plays%5B%22in_session%22%5D%20%26%20(attn_plays%5B%22dur%22%5D%20%3E%20240)%5D.copy()%0A%20%20%20%20_long%5B%22yr%22%5D%20%3D%20_long%5B%22played_at%22%5D.dt.year%0A%0A%20%20%20%20_by_year%20%3D%20_long.groupby(%22yr%22).agg(med%3D(%22listened%22%2C%20%22median%22)%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20n%3D(%22listened%22%2C%20%22size%22))%0A%20%20%20%20%23%202017%20is%20missing%20outright%20and%202018%20is%20the%20tracking%20gap%20Act%20I's%20spine_start%0A%20%20%20%20%23%20already%20exists%20to%20exclude%20%E2%80%94%20start%20the%20series%20where%20the%20data%20is%20dense.%0A%20%20%20%20_by_year%20%3D%20_by_year%5B(_by_year%5B%22n%22%5D%20%3E%3D%20100)%20%26%20(_by_year.index%20%3E%3D%202019)%5D%0A%0A%20%20%20%20_fig%2C%20_ax%20%3D%20plt.subplots(figsize%3D(11%2C%204.6))%0A%20%20%20%20_ax.plot(_by_year.index%2C%20_by_year%5B%22med%22%5D%2C%20color%3DATTN_BLUE%2C%20lw%3D2.2%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20marker%3D%22o%22%2C%20ms%3D8%2C%20zorder%3D3)%0A%20%20%20%20for%20_x%2C%20_v%20in%20_by_year%5B%22med%22%5D.items()%3A%0A%20%20%20%20%20%20%20%20_ax.annotate(f%22%7B_v%3A.0f%7D%22%2C%20(_x%2C%20_v)%2C%20textcoords%3D%22offset%20points%22%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20xytext%3D(0%2C%2011)%2C%20ha%3D%22center%22%2C%20fontsize%3D9.5%2C%20color%3DATTN_INK)%0A%20%20%20%20_ax.set_ylim(0%2C%20300)%0A%20%20%20%20_ax.set_ylabel(%22median%20seconds%20listened%22)%0A%20%20%20%20_ax.set_title(%22Eight%20years%2C%20within%208%25%5Cn%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22median%20seconds%20listened%20on%20tracks%20over%204%20minutes%20%E2%80%94%20while%20the%20%22%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%22genre%20mix%20turned%20over%20completely%22%2C%20fontsize%3D13%2C%20loc%3D%22left%22)%0A%20%20%20%20_ax.grid(axis%3D%22y%22%2C%20alpha%3D0.25)%3B%20_ax.set_axisbelow(True)%0A%20%20%20%20for%20_s%20in%20(%22top%22%2C%20%22right%22)%3A%0A%20%20%20%20%20%20%20%20_ax.spines%5B_s%5D.set_visible(False)%0A%20%20%20%20plt.tight_layout()%3B%20plt.show()%0A%0A%20%20%20%20_by_hour%20%3D%20_long.groupby(%22played_hour_local%22).agg(%0A%20%20%20%20%20%20%20%20med%3D(%22listened%22%2C%20%22median%22)%2C%20n%3D(%22listened%22%2C%20%22size%22))%0A%20%20%20%20_by_hour%20%3D%20_by_hour%5B_by_hour%5B%22n%22%5D%20%3E%3D%2080%5D%0A%20%20%20%20print(%22by%20hour%20of%20day%20(long%20tracks)%3A%20%22%0A%20%20%20%20%20%20%20%20%20%20f%22%7B_by_hour%5B'med'%5D.min()%3A.0f%7Ds%20%E2%80%93%20%7B_by_hour%5B'med'%5D.max()%3A.0f%7Ds%20%22%0A%20%20%20%20%20%20%20%20%20%20f%22across%20%7Blen(_by_hour)%7D%20hours%2C%20no%20trend%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%23%20Skipping%20isn't%20dislike%20%E2%80%94%20it's%20a%20mode%0A%0A%20%20%20%20Which%20artists%20do%20I%20skip%3F%20Asked%20naively%20the%20answer%20is%20garbage%2C%20because%20raw%20skip%0A%20%20%20%20rate%20is%20almost%20entirely%20a%20function%20of%20**track%20length**%3A%20TOOL%20looks%20unlistenable%0A%20%20%20%20(70%25%20abandoned)%20purely%20because%20TOOL%20writes%20ten-minute%20songs.%0A%0A%20%20%20%20So%20compare%20each%20artist%20to%20what%20the%20length%20baseline%20predicts.%20Take%20the%0A%20%20%20%20abandonment%20rate%20for%20each%20track-length%20bucket%2C%20sum%20it%20over%20an%20artist's%20plays%20to%0A%20%20%20%20get%20an%20**expected**%20number%20of%20abandonments%2C%20and%20divide%20the%20observed%20count%20by%20it.%0A%20%20%20%20%24O%2FE%20%3E%201%24%20means%20%22skipped%20more%20than%20their%20track%20lengths%20can%20explain.%22%0A%0A%20%20%20%20The%20result%20splits%20my%20library%20cleanly%20in%20two%20%E2%80%94%20and%20not%20along%20the%20axis%20you'd%0A%20%20%20%20guess%3A%0A%0A%20%20%20%20**BONES%20has%208%2C233%20in-session%20plays.%20He%20is%20far%20and%20away%20my%20most-played%20artist%2C%0A%20%20%20%20and%20he%20is%20abandoned%2029%25%20more%20than%20his%20track%20lengths%20predict.**%0A%0A%20%20%20%20That%20kills%20the%20obvious%20reading.%20Skipping%20isn't%20a%20dislike%20signal.%20The%20whole%0A%20%20%20%20underground-rap%20end%20of%20the%20library%20(Ramirez%2C%20Ghostemane%2C%20Pouya%2C%20Xavier%20Wulf%2C%0A%20%20%20%20Yung%20Gravy)%20is%20a%20**crate%20I%20shuffle%20through**%20%E2%80%94%20dozens%20of%20short%20tracks%2C%20sampled%0A%20%20%20%20and%20moved%20past%2C%20which%20is%20how%20that%20music%20is%20meant%20to%20be%20consumed.%20The%20nu-metal%0A%20%20%20%20and%20metalcore%20end%20(Static-X%2C%20Linkin%20Park%2C%20System%20Of%20A%20Down%2C%20Disturbed%2C%20Parkway%0A%20%20%20%20Drive)%20gets%20**heard%20out**%2C%20track%20by%20track.%0A%0A%20%20%20%20Two%20completely%20different%20listening%20behaviours%2C%20living%20in%20one%20account%2C%20and%20the%0A%20%20%20%20only%20reason%20we%20can%20see%20either%20of%20them%20is%20that%20a%20timestamp%20happened%20to%20mark%20the%0A%20%20%20%20start%20of%20a%20play%20instead%20of%20the%20end.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0A%40app.cell%0Adef%20_(attn_plays%2C%20pd)%3A%0A%20%20%20%20_d%20%3D%20attn_plays%5Battn_plays%5B%22in_session%22%5D%5D.copy()%0A%0A%20%20%20%20%23%20length%20baseline%3A%20P(abandon%20%7C%20track%20length%20bucket)%0A%20%20%20%20_bins%20%3D%20%5B0%2C%2090%2C%20120%2C%20150%2C%20180%2C%20210%2C%20240%2C%20270%2C%20300%2C%20360%2C%20420%2C%2010_000%5D%0A%20%20%20%20_d%5B%22dbin%22%5D%20%3D%20pd.cut(_d%5B%22dur%22%5D%2C%20_bins)%0A%20%20%20%20_baseline%20%3D%20_d.groupby(%22dbin%22%2C%20observed%3DTrue)%5B%22skipped%22%5D.mean()%0A%20%20%20%20_d%5B%22expected%22%5D%20%3D%20_d%5B%22dbin%22%5D.map(_baseline).astype(float)%0A%0A%20%20%20%20attn_patience%20%3D%20_d.groupby(%5B%22artist_id%22%2C%20%22artist_name%22%5D).agg(%0A%20%20%20%20%20%20%20%20n%3D(%22skipped%22%2C%20%22size%22)%2C%20observed%3D(%22skipped%22%2C%20%22sum%22)%2C%0A%20%20%20%20%20%20%20%20expected%3D(%22expected%22%2C%20%22sum%22)%2C%20med_dur%3D(%22dur%22%2C%20%22median%22))%0A%20%20%20%20attn_patience%20%3D%20attn_patience%5Battn_patience%5B%22n%22%5D%20%3E%3D%20120%5D%0A%20%20%20%20attn_patience%5B%22raw_skip_pct%22%5D%20%3D%20(100%20*%20attn_patience%5B%22observed%22%5D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%2F%20attn_patience%5B%22n%22%5D)%0A%20%20%20%20attn_patience%5B%22OE%22%5D%20%3D%20attn_patience%5B%22observed%22%5D%20%2F%20attn_patience%5B%22expected%22%5D%0A%20%20%20%20attn_patience%20%3D%20(attn_patience.reset_index(%22artist_id%22%2C%20drop%3DTrue)%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20.sort_values(%22OE%22))%0A%0A%20%20%20%20_cols%20%3D%20%5B%22n%22%2C%20%22med_dur%22%2C%20%22raw_skip_pct%22%2C%20%22OE%22%5D%0A%20%20%20%20print(%22P(abandon)%20by%20track%20length%20%E2%80%94%20the%20baseline%20everything%20is%20scored%20against%3A%22)%0A%20%20%20%20print((_baseline%20*%20100).round(1).to_string())%0A%20%20%20%20print(f%22%5Cn%7Blen(attn_patience)%7D%20artists%20with%20%3E%3D%20120%20in-session%20plays%5Cn%22)%0A%20%20%20%20print(%22---%20SHUFFLED%20THROUGH%20(abandoned%20more%20than%20length%20explains)%20---%22)%0A%20%20%20%20print(attn_patience.nlargest(10%2C%20%22OE%22)%5B_cols%5D.round(2).to_string())%0A%20%20%20%20print(%22%5Cn---%20HEARD%20OUT%20---%22)%0A%20%20%20%20print(attn_patience.nsmallest(10%2C%20%22OE%22)%5B_cols%5D.round(2).to_string())%0A%20%20%20%20return%0A%0A%0A%40app.cell(hide_code%3DTrue)%0Adef%20_(mo)%3A%0A%20%20%20%20mo.md(r%22%22%22%0A%20%20%20%20%23%23%20What%20this%20doesn't%20measure%0A%0A%20%20%20%20Three%20limits%20worth%20stating%20plainly%2C%20because%20each%20one%20bounds%20the%20claim%3A%0A%0A%20%20%20%201.%20**Spotify's%2030-second%20floor.**%20The%20recently-played%20feed%20only%20logs%20a%20track%0A%20%20%20%20%20%20%20once%20it's%20been%20played%20for%20roughly%2030%20seconds.%20Every%20instant%20bail%20%E2%80%94%20the%20ones%0A%20%20%20%20%20%20%20where%20I%20hear%20two%20bars%20and%20move%20on%20%E2%80%94%20is%20structurally%20invisible.%20So%20this%0A%20%20%20%20%20%20%20measures%20*how%20long%20I%20stay%20once%20I've%20committed%2030%20seconds*%2C%20not%20how%20often%20I%0A%20%20%20%20%20%20%20bail.%20The%20real%20abandonment%20rate%20is%20higher%20than%2047%25.%0A%20%20%20%202.%20**A%20missed%20poll%20looks%20like%20a%20finished%20track.**%20%60your_spotify%60%20polls%20the%0A%20%20%20%20%20%20%20recently-played%20endpoint%20on%20an%20interval%3B%20if%20it%20misses%20a%20play%2C%20the%20surviving%0A%20%20%20%20%20%20%20gap%20spans%20two%20tracks%20and%20reads%20as%20%22listened%20all%20the%20way%20through.%22%20About%204.0%25%0A%20%20%20%20%20%20%20of%20in-session%20gaps%20sit%20at%20~2%C3%97%20the%20track%20duration%20and%201.2%25%20at%20~3%C3%97%2C%20which%20is%0A%20%20%20%20%20%20%20the%20signature.%20This%20biases%20in%20the%20safe%20direction%20%E2%80%94%20it%20makes%20me%20look%20*more*%0A%20%20%20%20%20%20%20patient%20than%20I%20am%20%E2%80%94%20but%20it%20means%2047%25%20is%20a%20floor%2C%20not%20a%20point%20estimate.%0A%20%20%20%203.%20**Pauses%20are%20indistinguishable%20from%20patience.**%20Walking%20away%20mid-track%20and%0A%20%20%20%20%20%20%20coming%20back%20inflates%20the%20gap%20the%20same%20way%20a%20missed%20poll%20does.%20Same%20direction%2C%0A%20%20%20%20%20%20%20same%20caveat.%0A%0A%20%20%20%20A%20fourth%20thing%20I%20checked%20and%20am%20*not*%20reporting%20as%20a%20finding%3A%20whether%0A%20%20%20%20%22skippability%22%20is%20a%20direction%20in%20the%2050-D%20embedding%20from%20Act%20II.%20Regressing%0A%20%20%20%20%24%5Clog(O%2FE)%24%20on%20the%20artist%20vectors%20gives%20an%20in-sample%20%24R%5E2%24%20of%200.99%20and%20a%205-fold%0A%20%20%20%20**cross-validated%20%24R%5E2%24%20of%200.12%20%C2%B1%200.73**%20%E2%80%94%20an%20error%20bar%20six%20times%20the%20point%0A%20%20%20%20estimate.%20On%2054%20artists%20that%20is%20a%20null%20result%20with%20a%20very%20persuasive-looking%0A%20%20%20%20picture%20attached%2C%20which%20is%20the%20most%20dangerous%20kind.%20The%20threshold%20would%20need%20to%0A%20%20%20%20drop%20to%20~40%20plays%20(n%20%E2%89%88%20150%20artists)%20before%20that%20regression%20is%20worth%20running%0A%20%20%20%20again.%0A%20%20%20%20%22%22%22)%0A%20%20%20%20return%0A%0A%0Aif%20__name__%20%3D%3D%20%22__main__%22%3A%0A%20%20%20%20app.run()%0A
62ce26967c5016b95ea11182eed08017