Correct three performer genders via build-db --gender-corrections

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 22:38:42 +08:00
co-authored by Claude Fable 5
parent 02dfc794c4
commit 3a9eee19d1
5 changed files with 258 additions and 4 deletions
+3 -3
View File
@@ -67,7 +67,7 @@ Bam Bam,Camila Cabello Featuring Ed Sheeran,2022#66,mixed
Bandit,Juice WRLD & YoungBoy Never Broke Again,2020#63,male
Bang!,AJR,2021#56,male
Bank Account,21 Savage,2017#48,male
Barbie World,Nicki Minaj & Ice Spice With Aqua,2023#46,mixed
Barbie World,Nicki Minaj & Ice Spice With Aqua,2023#46,female
Bartier Cardi,Cardi B Featuring 21 Savage,2018#61,mixed
Be Alright,Dean Lewis,2019#54,male
Be Careful,Cardi B,2018#59,female
@@ -122,7 +122,7 @@ Bury A Friend,Billie Eilish,2019#73,female
Bury Me In Georgia,Kane Brown,2023#89,male
Butter,BTS,2021#11,male
Buy Dirt,Jordan Davis Featuring Luke Bryan,2022#49,male
"Bzrp Music Sessions, Vol. 53",Bizarrap & Shakira,2023#96,mixed
"Bzrp Music Sessions, Vol. 53",Bizarrap & Shakira,2023#96,female
Cake By The Ocean,DNCE,2016#18,mixed
Call Out My Name,The Weeknd,2018#78,male
Calling My Phone,Lil Tjay Featuring 6LACK,2021#33,male
@@ -251,7 +251,7 @@ Forever After All,Luke Combs,2021#18,male
Fortnight,Taylor Swift Featuring Post Malone,2024#22,mixed
Freaky Friday,Lil Dicky Featuring Chris Brown,2018#55,male
Free,Rumi & JINU: EJAE & Andrew Choi,2025#92,mixed
Friends,Marshmello & Anne-Marie,2018#26,mixed
Friends,Marshmello & Anne-Marie,2018#26,female
FTCU,Nicki Minaj,2024#88,female
Fukumean,Gunna,2023#31/2024#85,male
Gata Only,FloyyMenor X Cris Mj,2024#52,male
1 Title Artists Positions Performer Gender
67 Bandit Juice WRLD & YoungBoy Never Broke Again 2020#63 male
68 Bang! AJR 2021#56 male
69 Bank Account 21 Savage 2017#48 male
70 Barbie World Nicki Minaj & Ice Spice With Aqua 2023#46 mixed female
71 Bartier Cardi Cardi B Featuring 21 Savage 2018#61 mixed
72 Be Alright Dean Lewis 2019#54 male
73 Be Careful Cardi B 2018#59 female
122 Bury Me In Georgia Kane Brown 2023#89 male
123 Butter BTS 2021#11 male
124 Buy Dirt Jordan Davis Featuring Luke Bryan 2022#49 male
125 Bzrp Music Sessions, Vol. 53 Bizarrap & Shakira 2023#96 mixed female
126 Cake By The Ocean DNCE 2016#18 mixed
127 Call Out My Name The Weeknd 2018#78 male
128 Calling My Phone Lil Tjay Featuring 6LACK 2021#33 male
251 Fortnight Taylor Swift Featuring Post Malone 2024#22 mixed
252 Freaky Friday Lil Dicky Featuring Chris Brown 2018#55 male
253 Free Rumi & JINU: EJAE & Andrew Choi 2025#92 mixed
254 Friends Marshmello & Anne-Marie 2018#26 mixed female
255 FTCU Nicki Minaj 2024#88 female
256 Fukumean Gunna 2023#31/2024#85 male
257 Gata Only FloyyMenor X Cris Mj 2024#52 male
@@ -0,0 +1,4 @@
Title,Artist Credit,Performer Gender,Note
Barbie World,Nicki Minaj & Ice Spice With Aqua,female,Aqua credited for the sample; no male vocal in the lyrics
"Bzrp Music Sessions, Vol. 53",Bizarrap & Shakira,female,Bizarrap produces; no vocal part
Friends,Marshmello & Anne-Marie,female,Marshmello produces; no vocal part
1 Title Artist Credit Performer Gender Note
2 Barbie World Nicki Minaj & Ice Spice With Aqua female Aqua credited for the sample; no male vocal in the lyrics
3 Bzrp Music Sessions, Vol. 53 Bizarrap & Shakira female Bizarrap produces; no vocal part
4 Friends Marshmello & Anne-Marie female Marshmello produces; no vocal part
+20
View File
@@ -817,3 +817,23 @@
獨立;run1 之覆蓋=`run1`(144 首)`run1-rescue-444`
(1 首),步驟 5-2 輸入組裝時 song-444 之 run1 份取自
補送歸檔,組裝腳本並驗證三輪歌曲集合一致(145 首)。
- **performer_gender 語意定為「演唱聲音的性別」,新增
手工修正機制**:原值由 `build-db` 自署名藝人的
Wikidata 性別推導,將僅掛名不開口者(製作人、取樣
來源)也計入,致三首純女聲歌曲被判為 mixed:
〈Friends〉(Marshmello 僅製作)、〈Barbie World〉
(Aqua 為取樣掛名,Ken 聲部不在歌詞中)、〈Bzrp Music
Sessions, Vol. 53〉(Bizarrap 僅製作)。裁定:不另設
發話位置變項(單獨為男聲設變項說不通,女聲、非二元
聲部亦無對應變項),而是修正 performer_gender 本身——
其語意即演唱聲音的性別。機制:`build-db` 新增
`--gender-corrections` 選項,於推導後套用
`data/manual/performer-gender-corrections.csv`(研究者
手工編定,Title+Artist Credit 精確比對,查無即建置
失敗),鏡射 coding-corrections 前例;不直接改推導
規則,因署名層推導對其餘 880 首仍正確。下游數字隨
重建更新:wp 66 首成 female 50/mixed 12/male 3/
genderfluid 1;fe 144 首成 female 98/mixed 30/male 13/
genderfluid 2/non-binary 1;wpfe 145 首中署名含男性
且開口者 44 首。步驟 5 報告中三首之性別標示同步更正。
@@ -9,7 +9,8 @@ inputs: the year-end chart CSV and the output directory for the
review CSV files, given as the two positional command-line
arguments, and the optional inputs, each given as an
option: the lyrics cache directory, the Wikidata artist
snapshot CSV, and the settled coding table CSV. An omitted option
snapshot CSV, the settled coding table CSV, and the gender
correction table CSV. An omitted option
leaves its layer unloaded; a given option whose path does
not exist fails the build. Missing tables
are created on a fresh store; existing tables are never altered,
@@ -48,6 +49,12 @@ producer collective -- is not a performing act: it has no voice,
so its gender is inapplicable, and it takes no part in the
derivation. See `PerformerGenderDeriver.performer_gender`.
Once the performer genders are derived, an optional gender
correction table CSV overrides the performer gender of the songs
it names, matched by exact title and exact artist credit, with
its performer gender column stored verbatim; see
`GenderCorrectionImporter`.
On a successful build, two review CSV files, ``songs.csv`` and
``artists.csv``, are (re)written under the given output directory,
mirroring the stored songs and artists without their IDs; see
@@ -122,6 +129,9 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
parser.add_argument(
"--groups", type=Path, default=None,
help="the settled code group table CSV file to import")
parser.add_argument(
"--gender-corrections", type=Path, default=None,
help="the gender correction table CSV file to apply")
return parser.parse_args(argv)
@@ -687,6 +697,98 @@ class PerformerGenderDeriver:
return None
class GenderCorrectionImporter:
"""The gender-correction job: overrides the derived performer
gender of the stored songs it names."""
COLUMNS: tuple[str, ...] = (
"Title", "Artist Credit", "Performer Gender", "Note")
"""The required columns of the gender correction CSV file."""
def __init__(self, session: Session) -> None:
"""Initialize the importer.
:param session: The database session.
"""
self.__session: Session = session
def import_gender_corrections(self, path: Path | None) -> None:
"""Apply the gender correction table onto the stored songs.
A None input leaves the derived performer genders
untouched. Otherwise every row of the CSV file matches one
stored song by its exact title and exact artist credit and
sets ``Song.performer_gender`` to the row's performer
gender column verbatim; the note column is ignored. Apply
this after `PerformerGenderDeriver.derive_performer_genders`
has run, so a correction overrides the derived value. When
the method returns, the applied corrections are queryable
in the session.
:param path: The gender correction table CSV file to apply,
or None to skip the corrections.
:return: None.
:raises BuildError: When the file lacks a required column,
or a row names a song that the store does not have.
:raises OSError: When the file cannot be read.
"""
if path is None:
return
songs: dict[tuple[str, str], Song] = {
(x.title, x.artist_credit): x
for x in self.__session.scalars(sa.select(Song))}
with open(path, encoding="utf-8", newline="") as file:
reader: csv.DictReader[str] = csv.DictReader(file)
self.__check_columns(path, reader.fieldnames)
row: dict[str, str]
for row in reader:
self.__apply_correction(path, songs, row)
self.__session.flush()
@staticmethod
def __apply_correction(path: Path,
songs: dict[tuple[str, str], Song],
row: dict[str, str]) -> None:
"""Apply one gender correction row.
:param path: The gender correction CSV file, for the error
message.
:param songs: The stored songs, keyed by the title and the
artist credit.
:param row: The gender correction CSV row.
:return: None.
:raises BuildError: When the row names a song that the
store does not have.
"""
key: tuple[str, str] = (
row["Title"], row["Artist Credit"])
song: Song | None = songs.get(key)
if song is None:
raise BuildError(
f"{path}: no song \"{row['Title']}\" by"
f" \"{row['Artist Credit']}\"")
song.performer_gender = row["Performer Gender"]
@classmethod
def __check_columns(cls, path: Path,
fieldnames: Sequence[str] | None) -> None:
"""Verify the gender correction CSV file has the required
columns.
:param path: The gender correction CSV file.
:param fieldnames: The header row of the file, or None
when the file is empty.
:return: None.
:raises BuildError: When a required column is absent.
"""
header: Sequence[str] = fieldnames or ()
missing: list[str] = [
x for x in cls.COLUMNS if x not in header]
if len(missing) > 0:
raise BuildError(
f"{path}: missing column(s): {', '.join(missing)}")
class CodingImporter:
"""The coding-import job: loads the settled coding table onto
the stored songs."""
@@ -1091,6 +1193,8 @@ def main(argv: list[str] | None = None) -> int:
CaptureImporter(session).import_captures(
args.lyrics_dir, args.wikidata_csv)
PerformerGenderDeriver(session).derive_performer_genders()
GenderCorrectionImporter(session).import_gender_corrections(
args.gender_corrections)
CodingImporter(session).import_codings(args.codings)
GroupImporter(session).import_groups(args.groups)
counts = StoreCounts.get_instance(session)
+126
View File
@@ -270,6 +270,8 @@ class TestBuildDB(unittest.TestCase):
self.__dir / "artists_wikidata.csv"
self.__codings: Path = self.__dir / "codings.csv"
self.__groups: Path = self.__dir / "groups.csv"
self.__gender_corrections: Path = \
self.__dir / "gender_corrections.csv"
self.__write_chart(self.CHART_CSV)
config.set_settings(config.Settings(
SQLALCHEMY_DATABASE_URL="sqlite://",
@@ -1222,3 +1224,127 @@ class TestBuildDB(unittest.TestCase):
"--groups", str(self.__groups))
self.assertNotEqual(status, 0)
self.assertIn("missing column(s): Votes", stderr)
def __write_gender_corrections(self, content: str) -> None:
"""Write the gender correction CSV fixture.
:param content: The CSV content.
:return: None.
"""
self.__gender_corrections.write_text(
content, encoding="utf-8")
def test_gender_correction_overrides_derived_gender(
self) -> None:
"""Test that a correction row overrides an already-derived
performer gender, stored verbatim."""
self.__write_chart(self.GENDER_CHART_CSV)
self.__write_wikidata(self.GENDER_WIKIDATA_CSV)
self.__write_gender_corrections(
"Title,Artist Credit,Performer Gender,Note\n"
"Female Song,Adele & Taylor Swift,mixed,reviewed\n")
status: int
stderr: str
status, stderr = self.__run_build(
"--wikidata-csv", str(self.__wikidata),
"--gender-corrections", str(self.__gender_corrections))
self.assertEqual(status, 0)
self.assertEqual(
self.__performer_genders()["Female Song"], "mixed")
def test_gender_correction_sets_undetermined_gender(
self) -> None:
"""Test that a correction row sets the performer gender of
a song the derivation left undetermined."""
self.__write_chart(self.GENDER_CHART_CSV)
self.__write_wikidata(self.GENDER_WIKIDATA_CSV)
self.__write_gender_corrections(
"Title,Artist Credit,Performer Gender,Note\n"
"Unknown Song,Adele featuring Nobody,female,"
"reviewed\n")
status: int
stderr: str
status, stderr = self.__run_build(
"--wikidata-csv", str(self.__wikidata),
"--gender-corrections", str(self.__gender_corrections))
self.assertEqual(status, 0)
self.assertEqual(
self.__performer_genders()["Unknown Song"], "female")
def test_gender_correction_in_songs_csv(self) -> None:
"""Test that songs.csv mirrors the corrected performer
gender."""
self.__write_chart(self.GENDER_CHART_CSV)
self.__write_wikidata(self.GENDER_WIKIDATA_CSV)
self.__write_gender_corrections(
"Title,Artist Credit,Performer Gender,Note\n"
"Female Song,Adele & Taylor Swift,mixed,reviewed\n")
self.assertEqual(
self.__run_build(
"--wikidata-csv", str(self.__wikidata),
"--gender-corrections",
str(self.__gender_corrections))[0], 0)
rows: list[list[str]] = self.__read_csv_rows("songs.csv")
self.assertIn(("Female Song", "mixed"),
{(row[0], row[3]) for row in rows})
def test_omitted_gender_corrections_leaves_derived_gender(
self) -> None:
"""Test that an omitted correction option leaves the
derived performer genders untouched."""
self.__write_chart(self.GENDER_CHART_CSV)
self.__write_wikidata(self.GENDER_WIKIDATA_CSV)
self.__write_gender_corrections(
"Title,Artist Credit,Performer Gender,Note\n"
"Female Song,Adele & Taylor Swift,mixed,reviewed\n")
status: int
stderr: str
status, stderr = self.__run_build(
"--wikidata-csv", str(self.__wikidata))
self.assertEqual(status, 0)
self.assertEqual(
self.__performer_genders()["Female Song"], "female")
def test_gender_correction_unknown_song_fails(self) -> None:
"""Test that a correction row naming an unknown song fails
the build, naming the file and the offending title and
credit."""
self.__write_chart(self.GENDER_CHART_CSV)
self.__write_wikidata(self.GENDER_WIKIDATA_CSV)
self.__write_gender_corrections(
"Title,Artist Credit,Performer Gender,Note\n"
"Nowhere,Nobody,female,reviewed\n")
status: int
stderr: str
status, stderr = self.__run_build(
"--wikidata-csv", str(self.__wikidata),
"--gender-corrections", str(self.__gender_corrections))
self.assertNotEqual(status, 0)
self.assertIn(str(self.__gender_corrections), stderr)
self.assertIn("no song \"Nowhere\" by \"Nobody\"", stderr)
def test_gender_corrections_missing_column_fails(self) -> None:
"""Test that a gender correction CSV missing a required
column fails the build."""
self.__write_gender_corrections(
"Title,Artist Credit,Performer Gender\n"
"Hello,Adele,female\n")
status: int
stderr: str
status, stderr = self.__run_build(
"--gender-corrections", str(self.__gender_corrections))
self.assertNotEqual(status, 0)
self.assertIn("missing column(s): Note", stderr)
def test_missing_gender_corrections_csv_fails(self) -> None:
"""Test that a given but missing gender correction CSV
fails the build."""
status: int
stderr: str
status, stderr = self.__run_build(
"--gender-corrections", str(self.__gender_corrections))
self.assertNotEqual(status, 0)
self.assertIn("error:", stderr)
self.assertIn(str(self.__gender_corrections), stderr)
session: Session = self.__session()
self.assertEqual(list(session.scalars(sa.select(Song))), [])