Correct three performer genders via build-db --gender-corrections
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -67,7 +67,7 @@ Bam Bam,Camila Cabello Featuring Ed Sheeran,2022#66,mixed
|
|||||||
Bandit,Juice WRLD & YoungBoy Never Broke Again,2020#63,male
|
Bandit,Juice WRLD & YoungBoy Never Broke Again,2020#63,male
|
||||||
Bang!,AJR,2021#56,male
|
Bang!,AJR,2021#56,male
|
||||||
Bank Account,21 Savage,2017#48,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
|
Bartier Cardi,Cardi B Featuring 21 Savage,2018#61,mixed
|
||||||
Be Alright,Dean Lewis,2019#54,male
|
Be Alright,Dean Lewis,2019#54,male
|
||||||
Be Careful,Cardi B,2018#59,female
|
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
|
Bury Me In Georgia,Kane Brown,2023#89,male
|
||||||
Butter,BTS,2021#11,male
|
Butter,BTS,2021#11,male
|
||||||
Buy Dirt,Jordan Davis Featuring Luke Bryan,2022#49,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
|
Cake By The Ocean,DNCE,2016#18,mixed
|
||||||
Call Out My Name,The Weeknd,2018#78,male
|
Call Out My Name,The Weeknd,2018#78,male
|
||||||
Calling My Phone,Lil Tjay Featuring 6LACK,2021#33,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
|
Fortnight,Taylor Swift Featuring Post Malone,2024#22,mixed
|
||||||
Freaky Friday,Lil Dicky Featuring Chris Brown,2018#55,male
|
Freaky Friday,Lil Dicky Featuring Chris Brown,2018#55,male
|
||||||
Free,Rumi & JINU: EJAE & Andrew Choi,2025#92,mixed
|
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
|
FTCU,Nicki Minaj,2024#88,female
|
||||||
Fukumean,Gunna,2023#31/2024#85,male
|
Fukumean,Gunna,2023#31/2024#85,male
|
||||||
Gata Only,FloyyMenor X Cris Mj,2024#52,male
|
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
|
||||||
|
@@ -817,3 +817,23 @@
|
|||||||
獨立;run1 之覆蓋=`run1`(144 首)∪`run1-rescue-444`
|
獨立;run1 之覆蓋=`run1`(144 首)∪`run1-rescue-444`
|
||||||
(1 首),步驟 5-2 輸入組裝時 song-444 之 run1 份取自
|
(1 首),步驟 5-2 輸入組裝時 song-444 之 run1 份取自
|
||||||
補送歸檔,組裝腳本並驗證三輪歌曲集合一致(145 首)。
|
補送歸檔,組裝腳本並驗證三輪歌曲集合一致(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;wp∪fe 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
|
review CSV files, given as the two positional command-line
|
||||||
arguments, and the optional inputs, each given as an
|
arguments, and the optional inputs, each given as an
|
||||||
option: the lyrics cache directory, the Wikidata artist
|
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
|
leaves its layer unloaded; a given option whose path does
|
||||||
not exist fails the build. Missing tables
|
not exist fails the build. Missing tables
|
||||||
are created on a fresh store; existing tables are never altered,
|
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
|
so its gender is inapplicable, and it takes no part in the
|
||||||
derivation. See `PerformerGenderDeriver.performer_gender`.
|
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
|
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
|
||||||
@@ -122,6 +129,9 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--groups", type=Path, default=None,
|
"--groups", type=Path, default=None,
|
||||||
help="the settled code group table CSV file to import")
|
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)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
@@ -687,6 +697,98 @@ class PerformerGenderDeriver:
|
|||||||
return None
|
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:
|
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."""
|
||||||
@@ -1091,6 +1193,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
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()
|
PerformerGenderDeriver(session).derive_performer_genders()
|
||||||
|
GenderCorrectionImporter(session).import_gender_corrections(
|
||||||
|
args.gender_corrections)
|
||||||
CodingImporter(session).import_codings(args.codings)
|
CodingImporter(session).import_codings(args.codings)
|
||||||
GroupImporter(session).import_groups(args.groups)
|
GroupImporter(session).import_groups(args.groups)
|
||||||
counts = StoreCounts.get_instance(session)
|
counts = StoreCounts.get_instance(session)
|
||||||
|
|||||||
@@ -270,6 +270,8 @@ class TestBuildDB(unittest.TestCase):
|
|||||||
self.__dir / "artists_wikidata.csv"
|
self.__dir / "artists_wikidata.csv"
|
||||||
self.__codings: Path = self.__dir / "codings.csv"
|
self.__codings: Path = self.__dir / "codings.csv"
|
||||||
self.__groups: Path = self.__dir / "groups.csv"
|
self.__groups: Path = self.__dir / "groups.csv"
|
||||||
|
self.__gender_corrections: Path = \
|
||||||
|
self.__dir / "gender_corrections.csv"
|
||||||
self.__write_chart(self.CHART_CSV)
|
self.__write_chart(self.CHART_CSV)
|
||||||
config.set_settings(config.Settings(
|
config.set_settings(config.Settings(
|
||||||
SQLALCHEMY_DATABASE_URL="sqlite://",
|
SQLALCHEMY_DATABASE_URL="sqlite://",
|
||||||
@@ -1222,3 +1224,127 @@ class TestBuildDB(unittest.TestCase):
|
|||||||
"--groups", str(self.__groups))
|
"--groups", str(self.__groups))
|
||||||
self.assertNotEqual(status, 0)
|
self.assertNotEqual(status, 0)
|
||||||
self.assertIn("missing column(s): Votes", stderr)
|
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))), [])
|
||||||
|
|||||||
Reference in New Issue
Block a user