Add the cluster-keywords subcommand for the coding vocabulary

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 22:38:29 +08:00
co-authored by Claude Opus 5
parent c03add4868
commit 41dc3ea2ea
7 changed files with 501 additions and 0 deletions
@@ -4,6 +4,7 @@
# imacat@mail.imacat.idv.tw (imacat), 2026/8/4
"""The registry of the CLI subcommands."""
from .build_db import main as build_db_command
from .cluster_keywords import main as cluster_keywords_command
from .export_llm_input import main as export_llm_input_command
from .fetch_artists import main as fetch_artists_command
from .fetch_lyrics import main as fetch_lyrics_command
@@ -0,0 +1,270 @@
# Tools for A Feminist Audit of Pop Music.
# Copyright 2026 imacat. All rights reserved.
# Authors:
# imacat@mail.imacat.idv.tw (imacat), 2026/8/5
"""The deterministic clusterer of the pooled keywords.
Builds the coding vocabulary from the pooled keyword list, given as
the first positional command-line argument, by sentence-embedding
every keyword and clustering the embeddings: the group membership,
given as the second positional argument, and the group name
vocabulary, given as the third positional argument, are written as
plain files. The step is fully deterministic; no LLM call is
made.
"""
import argparse
import csv
import sys
import time
from pathlib import Path
from typing import Any
from ..utils import format_duration
MODEL: str = "sentence-transformers/all-mpnet-base-v2"
DEFAULT_CLUSTERS: int = 50
CLUSTER_EXTRA_MESSAGE: str = (
"cluster-keywords requires the optional \"cluster\""
" dependency group; install it with"
" pip install -e \"tools/[cluster]\"")
"""The error message shown when the heavy clustering dependencies
are not installed."""
def parse_args(argv: list[str] | None) -> argparse.Namespace:
"""Parse the command-line arguments.
:param argv: The command-line arguments, or None for
``sys.argv``.
:return: The parsed arguments.
"""
parser: argparse.ArgumentParser = argparse.ArgumentParser(
description="Build the coding vocabulary by clustering"
" the sentence embeddings of the pooled"
" keywords.")
parser.add_argument(
"keywords_txt", type=Path,
help="the pooled keyword list, one keyword per line")
parser.add_argument(
"groups_csv", type=Path,
help="the group membership CSV output file")
parser.add_argument(
"vocabulary_txt", type=Path,
help="the group name vocabulary text output file")
parser.add_argument(
"--model", default=MODEL,
help=f"the sentence embedding model (default \"{MODEL}\")")
parser.add_argument(
"--revision", default=None,
help="the model revision to pin (default: unpinned)")
parser.add_argument(
"--clusters", type=int, default=DEFAULT_CLUSTERS,
help=f"the number of clusters (default {DEFAULT_CLUSTERS})")
return parser.parse_args(argv)
def load_keywords(path: Path) -> list[str]:
"""Load and validate the pooled keyword list.
:param path: The path of the pooled keyword text file, one
keyword per line.
:return: The keywords, in file order.
:raises OSError: When the file cannot be read.
:raises ValueError: When the file has no keyword, or a
keyword is duplicated.
"""
text: str = path.read_text(encoding="utf-8")
lines: list[str] = text.split("\n")
if len(lines) > 0 and lines[-1] == "":
lines = lines[:-1]
if len(lines) == 0:
raise ValueError(f"{path}: no keywords")
seen: set[str] = set()
keyword: str
for keyword in lines:
if keyword in seen:
raise ValueError(
f"{path}: duplicate keyword \"{keyword}\"")
seen.add(keyword)
return lines
def encode_keywords(keywords: list[str], model_name: str,
revision: str | None) -> Any:
"""Encode the keywords into L2-normalized sentence embeddings.
:param keywords: The keywords to encode.
:param model_name: The sentence embedding model name.
:param revision: The model revision to pin, or None to use
the model's default revision.
:return: The float32 embeddings, one row per keyword, in the
given order.
:raises RuntimeError: When the optional clustering
dependencies are not installed.
"""
try:
import numpy as np
from sentence_transformers import SentenceTransformer
except ImportError as error:
raise RuntimeError(CLUSTER_EXTRA_MESSAGE) from error
kwargs: dict[str, Any] = {}
if revision is not None:
kwargs["revision"] = revision
model: Any = SentenceTransformer(
model_name, device="cpu", **kwargs)
texts: list[str] = [x.replace("-", " ") for x in keywords]
embeddings: Any = model.encode(
texts, normalize_embeddings=True)
return np.asarray(embeddings, dtype=np.float32)
def cluster_embeddings(embeddings: Any, n_clusters: int) -> Any:
"""Cluster the embeddings with ward-linkage agglomeration.
:param embeddings: The float32 embeddings, one row per
keyword.
:param n_clusters: The number of clusters to form.
:return: The cluster label of each embedding, in the given
order.
:raises RuntimeError: When the optional clustering
dependencies are not installed.
"""
try:
from sklearn.cluster import AgglomerativeClustering
except ImportError as error:
raise RuntimeError(CLUSTER_EXTRA_MESSAGE) from error
clustering: Any = AgglomerativeClustering(
n_clusters=n_clusters, linkage="ward")
return clustering.fit_predict(embeddings)
def build_groups(keywords: list[str], embeddings: Any,
labels: Any) -> dict[str, list[str]]:
"""Group the keywords by cluster label, named by their medoid.
The group name is its medoid: the member whose embedding has
the highest dot product with the cluster's mean vector
re-normalized to unit length. Ties break toward the
lexicographically smallest member.
:param keywords: The keywords, in embedding row order.
:param embeddings: The float32 embeddings, one row per
keyword.
:param labels: The cluster label of each keyword, in the same
order.
:return: The keyword members of every group, keyed by the
group's medoid name.
:raises RuntimeError: When the optional clustering
dependencies are not installed.
:raises ValueError: When two clusters yield the same medoid
name.
"""
try:
import numpy as np
except ImportError as error:
raise RuntimeError(CLUSTER_EXTRA_MESSAGE) from error
clusters: dict[int, list[int]] = {}
index: int
label: int
for index, label in enumerate(labels):
clusters.setdefault(int(label), []).append(index)
groups: dict[str, list[str]] = {}
indices: list[int]
for indices in clusters.values():
members: list[str] = [keywords[x] for x in indices]
vectors: Any = embeddings[indices]
mean_vector: Any = vectors.mean(axis=0)
norm: float = float(np.linalg.norm(mean_vector))
direction: Any = (
mean_vector / norm if norm > 0 else mean_vector)
scores: Any = vectors @ direction
best_score: float = float(scores.max())
medoid: str = min(
member for member, score in zip(members, scores)
if float(score) == best_score)
if medoid in groups:
raise ValueError(
f"duplicate medoid group name \"{medoid}\"")
groups[medoid] = members
return groups
def write_groups(path: Path, groups: dict[str, list[str]]) -> None:
"""Write the group membership CSV file.
Writes a CSV file with the header row ``Group,Keyword``, one
row per member keyword. Rows are sorted by group name
lexicographically, then by keyword lexicographically.
:param path: The path of the group membership CSV file to
write.
:param groups: The keyword members of every group, keyed by
the group's medoid name.
:return: None.
:raises OSError: When the file cannot be written.
"""
group: str
with open(path, "w", encoding="utf-8", newline="") as file:
writer: Any = csv.writer(file)
writer.writerow(["Group", "Keyword"])
for group in sorted(groups.keys()):
keyword: str
for keyword in sorted(groups[group]):
writer.writerow([group, keyword])
def write_vocabulary(path: Path,
groups: dict[str, list[str]]) -> None:
"""Write the group name vocabulary text file.
Writes a plain text file, one group name per line,
lexicographically sorted, UTF-8, LF line endings, with a
trailing newline.
:param path: The path of the vocabulary text file to write.
:param groups: The keyword members of every group, keyed by
the group's medoid name.
:return: None.
:raises OSError: When the file cannot be written.
"""
path.write_text(
"".join(f"{x}\n" for x in sorted(groups.keys())),
encoding="utf-8")
def main(argv: list[str] | None = None) -> int:
"""Cluster the pooled keywords into the coding vocabulary.
Writes the group membership CSV file and the group name
vocabulary text file.
:param argv: The command-line arguments, or None for
``sys.argv``.
:return: The exit status: 0 on success, non-zero on failure.
"""
started: float = time.monotonic()
args: argparse.Namespace = parse_args(argv)
try:
keywords: list[str] = load_keywords(args.keywords_txt)
except (OSError, ValueError) as error:
print(f"error: {error}", file=sys.stderr)
return 1
try:
embeddings: Any = encode_keywords(
keywords, args.model, args.revision)
labels: Any = cluster_embeddings(embeddings, args.clusters)
groups: dict[str, list[str]] = build_groups(
keywords, embeddings, labels)
except (RuntimeError, ValueError) as error:
print(f"error: {error}", file=sys.stderr)
return 1
args.groups_csv.parent.mkdir(parents=True, exist_ok=True)
args.vocabulary_txt.parent.mkdir(parents=True, exist_ok=True)
write_groups(args.groups_csv, groups)
write_vocabulary(args.vocabulary_txt, groups)
elapsed: str = format_duration(time.monotonic() - started)
print(
f"done: {len(keywords)} keywords clustered into"
f" {len(groups)} groups. {elapsed} elapsed.",
file=sys.stderr)
return 0