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:
@@ -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 Shark(2019#75)
|
||||||
|
的實際演唱者為 Hope Segoine(KTVB 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()
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
Reference in New Issue
Block a user