Derive the song performer gender and identify Pinkfong as Hope Segoine

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 22:38:39 +08:00
co-authored by Claude Opus 5
parent ea9df52bc3
commit bebf85814e
4 changed files with 195 additions and 3 deletions
+24
View File
@@ -576,3 +576,27 @@
更正而合流),逐字可對回的引述由 43,098 升至 44,122。 更正而合流),逐字可對回的引述由 43,098 升至 44,122。
更正表為外部參數而非寫死於程式——研究者的介入因此 更正表為外部參數而非寫死於程式——研究者的介入因此
出現在重現命令上,且逐列可審、可 diff。 出現在重現命令上,且逐列可審、可 diff。
## 2026-08-08
- **署名 Pinkfong 的演出者實體指認為 Hope Segoine**
Billboard 署名 Pinkfong 為品牌(Wikidata Q55735607,型態
brand),非演唱者;其唯一上榜曲 Baby Shark2019#75
的實際演唱者為 Hope SegoineKTVB 2019-03-06 報導、
Songfacts、經紀簡介,出處詳私人留存的
QuickStatements 草稿)。藉既有的
署名正規化機制(`ArtistImporter.CANONICAL_ARTIST_NAMES`
指認:歌曲的署名字串維持榜單所印的 "Pinkfong",解析出
的演出者實體為 Hope Segoine。品牌無性別可言,人有;
本表記錄的其他項目為拼寫正規化,此筆為實體指認,屬
研究者裁定。
- **歌曲層演出者性別的推導規則**`build-db` 於套用
Wikidata 快照後,為每首歌自全體署名者(primary 與
featured 一律計入)的性別推導 `performer_gender`:已知
性別有兩種以上相異值即 `mixed`——未知者不可能翻案;
已知者全部同值且無未知,取該值;其餘(已知同值但有
未知,或全未知)留空。值域不折疊:non-binary、
genderfluid、trans woman 依快照原值參與比較,與 male/
female 相異即計入 `mixed`。此欄同步鏡射於
`data/derived/songs.csv``Performer Gender` 欄。
@@ -40,6 +40,11 @@ spelling, except for the names listed in
``ArtistImporter.CANONICAL_ARTIST_NAMES``, which always store the ``ArtistImporter.CANONICAL_ARTIST_NAMES``, which always store the
canonical spelling regardless of which variant is seen first. canonical spelling regardless of which variant is seen first.
Once the artists carry their captured attributes, every song
takes a performer gender derived from the genders of the artists
credited on it, primary and featured alike; see
`PerformerGenderDeriver.performer_gender`.
On a successful build, two review CSV files, ``songs.csv`` and On a successful build, two review CSV files, ``songs.csv`` and
``artists.csv``, are (re)written under the given output directory, ``artists.csv``, are (re)written under the given output directory,
mirroring the stored songs and artists without their IDs; see mirroring the stored songs and artists without their IDs; see
@@ -311,6 +316,7 @@ class ArtistImporter:
"cris mj": "Cris MJ", "cris mj": "Cris MJ",
"mariah the scientist": "Mariah the Scientist", "mariah the scientist": "Mariah the Scientist",
"surf mesa": "Surf Mesa", "surf mesa": "Surf Mesa",
"pinkfong": "Hope Segoine",
} }
"""The canonical artist spellings, keyed by the case-folded """The canonical artist spellings, keyed by the case-folded
identity.""" identity."""
@@ -609,6 +615,66 @@ class CaptureImporter:
setattr(artist, attribute, row[column]) setattr(artist, attribute, row[column])
class PerformerGenderDeriver:
"""The performer-gender job: derives the song-level performer
gender from the genders of the credited artists."""
MIXED: str = "mixed"
"""The performer gender of a song whose credited artists do not
all share one gender."""
def __init__(self, session: Session) -> None:
"""Initialize the deriver.
:param session: The database session.
"""
self.__session: Session = session
def derive_performer_genders(self) -> None:
"""Set the performer gender of every stored song.
Reads the songs back from the database, including any songs
pending in the same session, and sets
``Song.performer_gender`` from the genders of the artists
credited on the song, primary and featured alike (see
`performer_gender`). When the method returns, the derived
performer genders are queryable in the session.
:return: None.
"""
song: Song
for song in self.__session.scalars(sa.select(Song)):
song.performer_gender = self.performer_gender(
[x.artist.gender for x in song.song_artists])
self.__session.flush()
@classmethod
def performer_gender(
cls, genders: Iterable[str | None]) -> str | None:
"""Combine the credited artists' genders into one value.
A gender that is None or empty counts as unknown. Two or
more distinct known genders give ``MIXED``, an unknown one
notwithstanding, as an unknown cannot undo a disagreement.
A single known gender shared by every credited artist gives
that gender. Anything else -- a single known gender
alongside an unknown one, or no known gender at all -- gives
None.
:param genders: The genders of the artists credited on one
song, in any order.
:return: The performer gender of the song, or None when it
is undetermined.
"""
values: list[str | None] = list(genders)
known: set[str] = {x for x in values if x}
if len(known) > 1:
return cls.MIXED
if len(known) == 1 and all(x for x in values):
return known.pop()
return None
class CodingImporter: class CodingImporter:
"""The coding-import job: loads the settled coding table onto """The coding-import job: loads the settled coding table onto
the stored songs.""" the stored songs."""
@@ -760,7 +826,8 @@ def reset_store(session: Session) -> None:
class CSVExporter: class CSVExporter:
"""Writes the review CSV files mirroring the working store.""" """Writes the review CSV files mirroring the working store."""
__SONGS_HEADER: tuple[str, ...] = ("Title", "Artists", "Positions") __SONGS_HEADER: tuple[str, ...] = (
"Title", "Artists", "Positions", "Performer Gender")
"""The header row of ``songs.csv``, for human readers.""" """The header row of ``songs.csv``, for human readers."""
__ARTISTS_HEADER: tuple[str, ...] = ( __ARTISTS_HEADER: tuple[str, ...] = (
"Name", "Wikidata QID", "Gender", "Type", "Genre", "Country", "Name", "Wikidata QID", "Gender", "Type", "Genre", "Country",
@@ -852,7 +919,8 @@ class CSVExporter:
for song in songs: for song in songs:
row: list[str] = [ row: list[str] = [
song.title, song.artist_credit, song.title, song.artist_credit,
self.__song_positions(song)] self.__song_positions(song),
song.performer_gender or ""]
rows.append(row) rows.append(row)
return rows return rows
@@ -912,6 +980,7 @@ def main(argv: list[str] | None = None) -> int:
ArtistImporter(session).import_artists() ArtistImporter(session).import_artists()
CaptureImporter(session).import_captures( CaptureImporter(session).import_captures(
args.lyrics_dir, args.wikidata_csv) args.lyrics_dir, args.wikidata_csv)
PerformerGenderDeriver(session).derive_performer_genders()
CodingImporter(session).import_codings(args.codings) CodingImporter(session).import_codings(args.codings)
counts = StoreCounts.get_instance(session) counts = StoreCounts.get_instance(session)
CSVExporter(session, args.derived_dir).write() CSVExporter(session, args.derived_dir).write()
+5
View File
@@ -41,6 +41,11 @@ class Song(Base):
"""The combined artist credit string as printed on the chart.""" """The combined artist credit string as printed on the chart."""
lyrics: Mapped[str | None] lyrics: Mapped[str | None]
"""The lyrics text, when available.""" """The lyrics text, when available."""
performer_gender: Mapped[str | None]
"""The gender of the credited performers taken together:
"mixed" when they disagree, their common gender when every
credited artist's gender is known and they agree, and None
otherwise."""
chart_entries: Mapped[list[ChartEntry]] \ chart_entries: Mapped[list[ChartEntry]] \
= relationship(back_populates="song") = relationship(back_populates="song")
"""The chart entries of the song.""" """The chart entries of the song."""
+95 -1
View File
@@ -292,6 +292,14 @@ class TestBuildDB(unittest.TestCase):
""" """
self.__chart.write_text(content, encoding="utf-8") self.__chart.write_text(content, encoding="utf-8")
def __write_wikidata(self, content: str) -> None:
"""Write the Wikidata artist snapshot CSV fixture.
:param content: The CSV content.
:return: None.
"""
self.__wikidata.write_text(content, encoding="utf-8")
def __write_codings(self, content: str) -> None: def __write_codings(self, content: str) -> None:
"""Write the coding CSV fixture. """Write the coding CSV fixture.
@@ -538,6 +546,92 @@ class TestBuildDB(unittest.TestCase):
assert artist is not None assert artist is not None
self.assertEqual(artist.name, "Surf Mesa") self.assertEqual(artist.name, "Surf Mesa")
def test_canonical_pinkfong_resolves_to_hope_segoine(
self) -> None:
"""Test that a "Pinkfong" credit stores the artist named
"Hope Segoine", leaving the printed credit untouched."""
self.__write_chart(
"year,rank,title,artist\n"
"2016,1,Baby Shark,Pinkfong\n"
"2016,2,filler,Filler Artist\n"
"2017,1,filler2,Filler Artist Two\n"
"2017,2,filler3,Filler Artist Three\n")
status: int
stderr: str
status, stderr = self.__run_build()
self.assertEqual(status, 0)
session: Session = self.__session()
song: Song | None = session.scalar(
sa.select(Song).where(Song.title == "Baby Shark"))
assert song is not None
self.assertEqual(song.artist_credit, "Pinkfong")
self.assertEqual(
[x.artist.name for x in song.song_artists],
["Hope Segoine"])
GENDER_CHART_CSV: str = (
"year,rank,title,artist\n"
"2016,1,Mixed Song,\"Adele, Drake & Nobody\"\n"
"2016,2,Female Song,Adele & Taylor Swift\n"
"2017,1,Unknown Song,Adele featuring Nobody\n"
"2017,2,No Gender Song,Nobody Else\n")
"""The chart CSV fixture exercising the performer gender: a
disagreement with an unknown artist, an all-known agreement, an
agreement with an unknown artist, and no known gender."""
GENDER_WIKIDATA_CSV: str = (
"name,qid,gender,type,genre,country,note\n"
"Adele,Q2831,female,solo,pop,GB,\n"
"Drake,Q33240,male,solo,hip-hop,CA,\n"
"Taylor Swift,Q26876,female,solo,pop,US,\n")
"""The artist snapshot fixture for the performer gender, leaving
"Nobody" and "Nobody Else" without a gender."""
def __performer_genders(self) -> dict[str, str | None]:
"""Read the stored performer genders keyed by the titles.
:return: The stored performer genders, keyed by the song
titles.
"""
session: Session = self.__session()
return {x.title: x.performer_gender
for x in session.scalars(sa.select(Song))}
def test_performer_gender_derived(self) -> None:
"""Test the performer gender of every song: a disagreement
gives "mixed" even with an unknown artist, an all-known
agreement gives that gender, and an unknown artist otherwise
leaves it unset."""
self.__write_chart(self.GENDER_CHART_CSV)
self.__write_wikidata(self.GENDER_WIKIDATA_CSV)
status: int
stderr: str
status, stderr = self.__run_build(
"--wikidata-csv", str(self.__wikidata))
self.assertEqual(status, 0)
self.assertEqual(
self.__performer_genders(),
{"Mixed Song": "mixed",
"Female Song": "female",
"Unknown Song": None,
"No Gender Song": None})
def test_performer_gender_in_songs_csv(self) -> None:
"""Test that songs.csv mirrors the performer gender, empty
when it is unset."""
self.__write_chart(self.GENDER_CHART_CSV)
self.__write_wikidata(self.GENDER_WIKIDATA_CSV)
self.assertEqual(
self.__run_build("--wikidata-csv",
str(self.__wikidata))[0], 0)
rows: list[list[str]] = self.__read_csv_rows("songs.csv")
self.assertEqual(
[(row[0], row[3]) for row in rows],
[("Female Song", "female"),
("Mixed Song", "mixed"),
("No Gender Song", ""),
("Unknown Song", "")])
def test_first_run_on_fresh_store(self) -> None: def test_first_run_on_fresh_store(self) -> None:
"""Test that a build on a fresh store creates the tables.""" """Test that a build on a fresh store creates the tables."""
self.assertEqual( self.assertEqual(
@@ -727,7 +821,7 @@ class TestBuildDB(unittest.TestCase):
self.assertEqual(self.__run_build()[0], 0) self.assertEqual(self.__run_build()[0], 0)
self.assertEqual( self.assertEqual(
self.__read_csv_header("songs.csv"), self.__read_csv_header("songs.csv"),
["Title", "Artists", "Positions"]) ["Title", "Artists", "Positions", "Performer Gender"])
rows: list[list[str]] = self.__read_csv_rows("songs.csv") rows: list[list[str]] = self.__read_csv_rows("songs.csv")
self.assertEqual( self.assertEqual(
[row[:2] for row in rows], [row[:2] for row in rows],