Separate the clustered keyword list from the keywords to merge

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 22:38:30 +08:00
co-authored by Claude Opus 5
parent 04d5095e44
commit 5cf2b8ee8c
5 changed files with 164 additions and 46 deletions
+9 -7
View File
@@ -47,11 +47,12 @@ LLM。設計原則見 `research-plan.md`;本檔記載可重現的
實作本步,四種模型六次執行全部無法維持完整分割, 實作本步,四種模型六次執行全部無法維持完整分割,
已棄用(詳見 `decision-log.md` 2026-08-05;棄用的 已棄用(詳見 `decision-log.md` 2026-08-05;棄用的
定義檔止於 git 歷史,見 `git log -- prompts/`)。 定義檔止於 git 歷史,見 `git log -- prompts/`)。
- **產物**:分群明細 CSV(欄位 Group、Keyword一列一個 - **產物**三份。分群明細 CSV(欄位 Group、Keyword
成員,依兩欄排序)記錄機器算出的分割;定案碼表 JSON 一列一個成員,依兩欄排序)記錄機器算出的分割;組名
`{"keywords": [...]}`)記錄實際交給模型的碼,即組名 純文字檔(一行一個,字典序)是分群結果的可讀清單;
加上先驗主題詞。兩者分開,因為前者是分群結果、後者 定案碼表 JSON`{"keywords": [...]}`)記錄實際交給
含研究者的介入。 模型的碼,即組名加上先驗主題詞。前兩份只含分群結果,
只有第三份含研究者的介入。
- **可重現性**:同一輸入、同一釘定模型、同一參數逐次 - **可重現性**:同一輸入、同一釘定模型、同一參數逐次
重現。不同 CPU/BLAS 實作的浮點尾數差異可能使邊界 重現。不同 CPU/BLAS 實作的浮點尾數差異可能使邊界
詞的歸屬翻動,屬已揭露的限制;論文所用碼表逐字 詞的歸屬翻動,屬已揭露的限制;論文所用碼表逐字
@@ -114,8 +115,9 @@ LLM。設計原則見 `research-plan.md`;本檔記載可重現的
U+0085 等控制字元時,`str.splitlines()` 類的通用切行 U+0085 等控制字元時,`str.splitlines()` 類的通用切行
會截斷 JSON 字串,實測踩中),輸出關鍵字純文字檔與 會截斷 JSON 字串,實測踩中),輸出關鍵字純文字檔與
出處 CSV。 出處 CSV。
- **步驟 2-1 → 2-2**`cluster-keywords` 讀關鍵字純文字 - **步驟 2-1 → 2-2**`cluster-keywords` 讀關鍵字
檔,輸出分群明細 CSV 與定案碼表 JSON。 文字檔,輸出分群明細 CSV、組名純文字檔與定案碼表
JSON。
- **步驟 2-2 → 3 輸入檔**`export-llm-input --extras - **步驟 2-2 → 3 輸入檔**`export-llm-input --extras
<定案碼表>` 自工作儲存產出步驟 3 的輸入,每筆 <定案碼表>` 自工作儲存產出步驟 3 的輸入,每筆
`{"id": "song-<ID>", "content": <字串>}``content` 為 `{"id": "song-<ID>", "content": <字串>}``content` 為
+50
View File
@@ -0,0 +1,50 @@
abandonment-and-solitude
alcohol-and-substance-abuse
attraction-and-admiration
betrayal-and-mistrust
boastful-self-confidence
breakup-and-reconciliation
celebration-and-partying
contentment-and-joy
dancing-and-movement
defiance-and-confrontation
denial-and-pretense
designer-fashion-flexing
devotion-and-sacrifice
embracing-true-identity
fame-and-status
family-and-fatherhood
fantasy-and-imagination
fear-of-vulnerability
female-empowerment
flirtation-and-seduction
freedom-and-escape
heartbreak-and-grief
hidden-emotional-struggle
hope-and-disillusionment
human-connection
hustle-and-self-made-success
independence-and-self-reliance
intimacy-and-connection
jealousy-and-resentment-from-others
longing-and-desire
longing-for-clarity
love-as-fleeting-and-transient
loyalty-and-commitment
mutual-individuality
nostalgia-and-memory
obsession-and-madness
partner-inadequacy
partying-and-nightlife
personal-growth-and-change
reggaeton-culture
regret-and-guilt
relentless-ambition
resilience-through-hardship
rivalry-and-superiority
romantic-pursuit
secrecy-and-paranoia
self-worth-and-insecurity
small-town-roots-and-identity
street-loyalty-and-danger
wealth-and-luxury
@@ -8,11 +8,14 @@ Builds the coding groups from the pooled keyword list, given as
the first positional command-line argument, by sentence-embedding the first positional command-line argument, by sentence-embedding
every keyword and clustering the embeddings: the group membership, every keyword and clustering the embeddings: the group membership,
given as the second positional argument, is written as a CSV file given as the second positional argument, is written as a CSV file
holding the clustering result alone. The coding keyword set, holding the clustering result alone. The group name keywords
given as the third positional argument, is written as a JSON file alone, given as the third positional argument, are written as a
holding the group name keywords plus the researcher's a-priori text file, one per line. The coding keyword set for
topic term (see :data:`EXTRA_KEYWORD`). The step is fully ``export-llm-input --extras``, given as the fourth positional
deterministic; no LLM call is made. argument, is written as a JSON file holding the group name
keywords plus the researcher's a-priori topic term (see
:data:`EXTRA_KEYWORD`). The step is fully deterministic; no LLM
call is made.
""" """
import argparse import argparse
import csv import csv
@@ -49,14 +52,17 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
" sentence embeddings of the pooled" " sentence embeddings of the pooled"
" keywords.") " keywords.")
parser.add_argument( parser.add_argument(
"keywords_txt", type=Path, "pool_txt", type=Path,
help="the pooled keyword list, one keyword per line") help="the pooled keyword list, one keyword per line")
parser.add_argument( parser.add_argument(
"groups_csv", type=Path, "groups_csv", type=Path,
help="the group membership CSV output file") help="the group membership CSV output file")
parser.add_argument( parser.add_argument(
"keywords_json", type=Path, "keywords_txt", type=Path,
help="the group name keyword JSON output file") help="the group name keyword text output file")
parser.add_argument(
"keywords_to_merge_json", type=Path,
help="the coding keyword set JSON output file")
parser.add_argument( parser.add_argument(
"--model", default=MODEL, "--model", default=MODEL,
help=f"the sentence embedding model (default \"{MODEL}\")") help=f"the sentence embedding model (default \"{MODEL}\")")
@@ -221,14 +227,35 @@ def write_groups(path: Path, groups: dict[str, list[str]]) -> None:
writer.writerow([group, keyword]) writer.writerow([group, keyword])
def write_keywords(path: Path, def write_keyword_names(path: Path,
groups: dict[str, list[str]]) -> None: groups: dict[str, list[str]]) -> None:
"""Write the group name keyword text file.
Writes a text file holding the lexicographically sorted
group names, one per line, UTF-8, LF line endings, with a
trailing newline. The file records the clustering result
alone; it holds no :data:`EXTRA_KEYWORD` line.
:param path: The path of the keyword 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.
"""
names: list[str] = sorted(groups.keys())
path.write_text(
"".join(f"{x}\n" for x in names), encoding="utf-8")
def write_keywords_to_merge(path: Path,
groups: dict[str, list[str]]) -> None:
"""Write the coding keyword set JSON file. """Write the coding keyword set JSON file.
Writes a JSON file holding a single object with one Writes a JSON file holding a single object with one
``keywords`` key, whose value is the lexicographically ``keywords`` key, whose value is the lexicographically
sorted list of the group names plus :data:`EXTRA_KEYWORD`, sorted list of the group names plus :data:`EXTRA_KEYWORD`,
UTF-8, with a trailing newline. UTF-8, with a trailing newline. This is the file
``export-llm-input --extras`` consumes.
:param path: The path of the keyword JSON file to write. :param path: The path of the keyword JSON file to write.
:param groups: The keyword members of every group, keyed by :param groups: The keyword members of every group, keyed by
@@ -248,8 +275,10 @@ def main(argv: list[str] | None = None) -> int:
"""Cluster the pooled keywords into the coding groups. """Cluster the pooled keywords into the coding groups.
Writes the group membership CSV file, holding the clustering Writes the group membership CSV file, holding the clustering
result alone, and the coding keyword set JSON file, holding result alone; the group name keyword text file, holding the
the group names plus :data:`EXTRA_KEYWORD`. same group names as a readable list; and the coding keyword
set JSON file, holding the group names plus
:data:`EXTRA_KEYWORD`.
:param argv: The command-line arguments, or None for :param argv: The command-line arguments, or None for
``sys.argv``. ``sys.argv``.
@@ -258,7 +287,7 @@ def main(argv: list[str] | None = None) -> int:
started: float = time.monotonic() started: float = time.monotonic()
args: argparse.Namespace = parse_args(argv) args: argparse.Namespace = parse_args(argv)
try: try:
keywords: list[str] = load_keywords(args.keywords_txt) keywords: list[str] = load_keywords(args.pool_txt)
except (OSError, ValueError) as error: except (OSError, ValueError) as error:
print(f"error: {error}", file=sys.stderr) print(f"error: {error}", file=sys.stderr)
return 1 return 1
@@ -272,9 +301,12 @@ def main(argv: list[str] | None = None) -> int:
print(f"error: {error}", file=sys.stderr) print(f"error: {error}", file=sys.stderr)
return 1 return 1
args.groups_csv.parent.mkdir(parents=True, exist_ok=True) args.groups_csv.parent.mkdir(parents=True, exist_ok=True)
args.keywords_json.parent.mkdir(parents=True, exist_ok=True) args.keywords_txt.parent.mkdir(parents=True, exist_ok=True)
args.keywords_to_merge_json.parent.mkdir(
parents=True, exist_ok=True)
write_groups(args.groups_csv, groups) write_groups(args.groups_csv, groups)
write_keywords(args.keywords_json, groups) write_keyword_names(args.keywords_txt, groups)
write_keywords_to_merge(args.keywords_to_merge_json, groups)
elapsed: str = format_duration(time.monotonic() - started) elapsed: str = format_duration(time.monotonic() - started)
print( print(
f"done: {len(keywords)} keywords clustered into" f"done: {len(keywords)} keywords clustered into"
+57 -23
View File
@@ -31,9 +31,11 @@ class TestClusterKeywords(unittest.TestCase):
= tempfile.TemporaryDirectory() = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup) self.addCleanup(tmp.cleanup)
self.__dir: Path = Path(tmp.name) self.__dir: Path = Path(tmp.name)
self.__keywords_txt: Path = self.__dir / "keywords.txt" self.__pool_txt: Path = self.__dir / "pool.txt"
self.__groups_csv: Path = self.__dir / "groups.csv" self.__groups_csv: Path = self.__dir / "groups.csv"
self.__keywords_json: Path = self.__dir / "keywords.json" self.__keywords_txt: Path = self.__dir / "keywords.txt"
self.__keywords_to_merge_json: Path \
= self.__dir / "keywords-to-merge.json"
@staticmethod @staticmethod
def __two_cluster_vectors() -> Vectors: def __two_cluster_vectors() -> Vectors:
@@ -56,13 +58,13 @@ class TestClusterKeywords(unittest.TestCase):
"b-south": (-0.9396926, -0.3420201), "b-south": (-0.9396926, -0.3420201),
} }
def __write_keywords(self, keywords: list[str]) -> None: def __write_pool(self, keywords: list[str]) -> None:
"""Write the pooled keyword input file. """Write the pooled keyword input file.
:param keywords: The keywords, one per line. :param keywords: The keywords, one per line.
:return: None. :return: None.
""" """
self.__keywords_txt.write_text( self.__pool_txt.write_text(
"".join(f"{x}\n" for x in keywords), encoding="utf-8") "".join(f"{x}\n" for x in keywords), encoding="utf-8")
@staticmethod @staticmethod
@@ -95,15 +97,16 @@ class TestClusterKeywords(unittest.TestCase):
standard error. standard error.
:param extra_args: Extra command-line arguments appended :param extra_args: Extra command-line arguments appended
after the three positional arguments. after the four positional arguments.
:param vectors: The fixed embedding to encode with; the :param vectors: The fixed embedding to encode with; the
two-cluster fixture is used when None. two-cluster fixture is used when None.
:return: A tuple of the exit status and the standard :return: A tuple of the exit status and the standard
error. error.
""" """
argv: list[str] = [ argv: list[str] = [
str(self.__keywords_txt), str(self.__groups_csv), str(self.__pool_txt), str(self.__groups_csv),
str(self.__keywords_json)] str(self.__keywords_txt),
str(self.__keywords_to_merge_json)]
argv.extend(extra_args or []) argv.extend(extra_args or [])
fake: Any = self.__fake_encode( fake: Any = self.__fake_encode(
vectors if vectors is not None vectors if vectors is not None
@@ -126,19 +129,33 @@ class TestClusterKeywords(unittest.TestCase):
newline="") as file: newline="") as file:
return list(csv.reader(file)) return list(csv.reader(file))
def __read_keywords(self) -> list[str]: def __read_keyword_names(self) -> list[str]:
"""Read the group name keyword JSON file. """Read the group name keyword text file.
:return: The group names under the "keywords" key. :return: The group names, in file order.
"""
text: str = self.__keywords_txt.read_text(
encoding="utf-8")
lines: list[str] = text.split("\n")
if len(lines) > 0 and lines[-1] == "":
lines = lines[:-1]
return lines
def __read_keywords_to_merge(self) -> list[str]:
"""Read the coding keyword set JSON file.
:return: The group names plus :data:`EXTRA_KEYWORD`
under the "keywords" key.
""" """
data: dict[str, list[str]] = json.loads( data: dict[str, list[str]] = json.loads(
self.__keywords_json.read_text(encoding="utf-8")) self.__keywords_to_merge_json.read_text(
encoding="utf-8"))
return data["keywords"] return data["keywords"]
def test_groups_csv_header_and_ordering(self) -> None: def test_groups_csv_header_and_ordering(self) -> None:
"""Test the header row and the group/keyword ordering of """Test the header row and the group/keyword ordering of
the group membership CSV file.""" the group membership CSV file."""
self.__write_keywords([ self.__write_pool([
"a-left", "a-center", "a-right", "a-left", "a-center", "a-right",
"b-north", "b-middle", "b-south"]) "b-north", "b-middle", "b-south"])
status: int status: int
@@ -161,7 +178,7 @@ class TestClusterKeywords(unittest.TestCase):
keywords: list[str] = [ keywords: list[str] = [
"a-left", "a-center", "a-right", "a-left", "a-center", "a-right",
"b-north", "b-middle", "b-south"] "b-north", "b-middle", "b-south"]
self.__write_keywords(keywords) self.__write_pool(keywords)
status: int status: int
status, _ = self.__run_cluster() status, _ = self.__run_cluster()
self.assertEqual(status, 0) self.assertEqual(status, 0)
@@ -169,16 +186,31 @@ class TestClusterKeywords(unittest.TestCase):
self.assertEqual( self.assertEqual(
sorted(x[1] for x in rows), sorted(keywords)) sorted(x[1] for x in rows), sorted(keywords))
def test_keywords_json_sorted_medoids(self) -> None: def test_keywords_txt_sorted_medoids(self) -> None:
"""Test that the keyword JSON file holds the sorted medoid """Test that the keyword text file holds the sorted medoid
group names plus the extra a-priori keyword.""" group names without the extra a-priori keyword."""
self.__write_keywords([ self.__write_pool([
"a-left", "a-center", "a-right", "a-left", "a-center", "a-right",
"b-north", "b-middle", "b-south"]) "b-north", "b-middle", "b-south"])
status: int status: int
status, _ = self.__run_cluster() status, _ = self.__run_cluster()
self.assertEqual(status, 0) self.assertEqual(status, 0)
keywords: list[str] = self.__read_keywords() names: list[str] = self.__read_keyword_names()
self.assertEqual(names, ["a-center", "b-middle"])
self.assertEqual(names, sorted(names))
self.assertNotIn(cluster_keywords.EXTRA_KEYWORD, names)
def test_keywords_to_merge_json_sorted_medoids(self) -> None:
"""Test that the coding keyword set JSON file holds the
sorted medoid group names plus the extra a-priori
keyword."""
self.__write_pool([
"a-left", "a-center", "a-right",
"b-north", "b-middle", "b-south"])
status: int
status, _ = self.__run_cluster()
self.assertEqual(status, 0)
keywords: list[str] = self.__read_keywords_to_merge()
self.assertEqual( self.assertEqual(
keywords, keywords,
["a-center", "b-middle", cluster_keywords.EXTRA_KEYWORD]) ["a-center", "b-middle", cluster_keywords.EXTRA_KEYWORD])
@@ -188,7 +220,7 @@ class TestClusterKeywords(unittest.TestCase):
def test_extra_keyword_absent_from_groups_csv(self) -> None: def test_extra_keyword_absent_from_groups_csv(self) -> None:
"""Test that the extra a-priori keyword appears in no row """Test that the extra a-priori keyword appears in no row
of the group membership CSV file.""" of the group membership CSV file."""
self.__write_keywords([ self.__write_pool([
"a-left", "a-center", "a-right", "a-left", "a-center", "a-right",
"b-north", "b-middle", "b-south"]) "b-north", "b-middle", "b-south"])
status: int status: int
@@ -201,23 +233,25 @@ class TestClusterKeywords(unittest.TestCase):
def test_duplicate_keyword_rejected(self) -> None: def test_duplicate_keyword_rejected(self) -> None:
"""Test that a duplicate keyword line fails the run """Test that a duplicate keyword line fails the run
without writing any output file.""" without writing any output file."""
self.__write_keywords(["shared", "shared"]) self.__write_pool(["shared", "shared"])
status: int status: int
stderr: str stderr: str
status, stderr = self.__run_cluster() status, stderr = self.__run_cluster()
self.assertEqual(status, 1) self.assertEqual(status, 1)
self.assertIn("duplicate keyword", stderr) self.assertIn("duplicate keyword", stderr)
self.assertFalse(self.__groups_csv.exists()) self.assertFalse(self.__groups_csv.exists())
self.assertFalse(self.__keywords_json.exists()) self.assertFalse(self.__keywords_txt.exists())
self.assertFalse(self.__keywords_to_merge_json.exists())
def test_empty_input_rejected(self) -> None: def test_empty_input_rejected(self) -> None:
"""Test that an empty keyword file fails the run without """Test that an empty keyword file fails the run without
writing any output file.""" writing any output file."""
self.__keywords_txt.write_text("", encoding="utf-8") self.__pool_txt.write_text("", encoding="utf-8")
status: int status: int
stderr: str stderr: str
status, stderr = self.__run_cluster() status, stderr = self.__run_cluster()
self.assertEqual(status, 1) self.assertEqual(status, 1)
self.assertIn("no keywords", stderr) self.assertIn("no keywords", stderr)
self.assertFalse(self.__groups_csv.exists()) self.assertFalse(self.__groups_csv.exists())
self.assertFalse(self.__keywords_json.exists()) self.assertFalse(self.__keywords_txt.exists())
self.assertFalse(self.__keywords_to_merge_json.exists())