Restructure cluster-keywords into builders and result records
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -64,7 +64,7 @@ A general command that runs specific LLM instructions with the Anthropic API. T
|
|||||||
cluster-keywords
|
cluster-keywords
|
||||||
----------------
|
----------------
|
||||||
|
|
||||||
Deterministically build the coding vocabulary from the two tagging runs' archives, by pooling their keywords per the project's handoff contract and then sentence-embedding and clustering them. Requires the optional ``cluster`` dependency group. Check ``pop-fem-audit-tools cluster-keywords -h`` for complete instructions on its usage.
|
Deterministically build the coding vocabulary from the two tagging runs' archives, by pooling their keywords per the project's handoff contract and then sentence-embedding and clustering them. Check ``pop-fem-audit-tools cluster-keywords -h`` for complete instructions on its usage.
|
||||||
|
|
||||||
|
|
||||||
compare-codings
|
compare-codings
|
||||||
|
|||||||
@@ -40,13 +40,11 @@ dependencies = [
|
|||||||
"pydantic-settings >= 2",
|
"pydantic-settings >= 2",
|
||||||
"SQLAlchemy >= 2",
|
"SQLAlchemy >= 2",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
]
|
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
cluster = [
|
|
||||||
"torch",
|
"torch",
|
||||||
"sentence-transformers",
|
"sentence-transformers",
|
||||||
"scikit-learn",
|
"scikit-learn",
|
||||||
|
"numpy",
|
||||||
|
"transformers",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,12 +3,13 @@
|
|||||||
# Authors:
|
# Authors:
|
||||||
# imacat@mail.imacat.idv.tw (imacat), 2026/8/5
|
# imacat@mail.imacat.idv.tw (imacat), 2026/8/5
|
||||||
"""Unit tests for the keyword clusterer module."""
|
"""Unit tests for the keyword clusterer module."""
|
||||||
|
import argparse
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from contextlib import redirect_stderr
|
from contextlib import ExitStack, redirect_stderr
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -38,18 +39,20 @@ class TestClusterKeywords(unittest.TestCase):
|
|||||||
self.__output_dir: Path = self.__dir / "output"
|
self.__output_dir: Path = self.__dir / "output"
|
||||||
self.__source_keywords_txt: Path \
|
self.__source_keywords_txt: Path \
|
||||||
= self.__output_dir \
|
= self.__output_dir \
|
||||||
/ cluster_keywords.SOURCE_KEYWORDS_TXT
|
/ cluster_keywords.PooledKeywords.SOURCE_KEYWORDS_TXT
|
||||||
self.__result_keywords_txt: Path \
|
self.__result_keywords_txt: Path \
|
||||||
= self.__output_dir \
|
= self.__output_dir \
|
||||||
/ cluster_keywords.RESULT_KEYWORDS_TXT
|
/ cluster_keywords.KeywordGroups.RESULT_KEYWORDS_TXT
|
||||||
self.__result_groups_csv: Path \
|
self.__result_groups_csv: Path \
|
||||||
= self.__output_dir \
|
= self.__output_dir \
|
||||||
/ cluster_keywords.RESULT_GROUPS_CSV
|
/ cluster_keywords.KeywordGroups.RESULT_GROUPS_CSV
|
||||||
self.__keywords_to_merge_json: Path \
|
self.__keywords_to_merge_json: Path \
|
||||||
= self.__output_dir \
|
= self.__output_dir \
|
||||||
/ cluster_keywords.KEYWORDS_TO_MERGE_JSON
|
/ cluster_keywords.KeywordsToMerge \
|
||||||
|
.KEYWORDS_TO_MERGE_JSON
|
||||||
self.__meta_json: Path \
|
self.__meta_json: Path \
|
||||||
= self.__output_dir / cluster_keywords.META_JSON
|
= self.__output_dir \
|
||||||
|
/ cluster_keywords.RunMeta.META_JSON
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __write_output(
|
def __write_output(
|
||||||
@@ -88,13 +91,13 @@ class TestClusterKeywords(unittest.TestCase):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __fake_encode(vectors: Vectors) -> Any:
|
def __fake_encode(vectors: Vectors) -> Any:
|
||||||
"""Build a test double for :func:`encode_keywords`.
|
"""Build a test double for the clusterer's encoder.
|
||||||
|
|
||||||
:param vectors: The fixed 2D embedding of every keyword
|
:param vectors: The fixed 2D embedding of every keyword
|
||||||
the double may be asked to encode.
|
the double may be asked to encode.
|
||||||
:return: A callable with the same signature as
|
:return: A callable with the same signature as the
|
||||||
:func:`encode_keywords`, returning the fixed
|
clusterer's encoder, returning the fixed embeddings in
|
||||||
embeddings in the requested keyword order.
|
the requested keyword order.
|
||||||
"""
|
"""
|
||||||
def fake(keywords: list[str], model_name: str,
|
def fake(keywords: list[str], model_name: str,
|
||||||
revision: str | None) -> Any:
|
revision: str | None) -> Any:
|
||||||
@@ -113,8 +116,8 @@ class TestClusterKeywords(unittest.TestCase):
|
|||||||
def __fake_versions() -> dict[str, str]:
|
def __fake_versions() -> dict[str, str]:
|
||||||
"""Return a fixed version mapping test double.
|
"""Return a fixed version mapping test double.
|
||||||
|
|
||||||
:return: A fixed mapping with the same keys
|
:return: A fixed mapping with the same keys the metadata
|
||||||
:func:`cluster_keywords.collect_versions` returns.
|
builder's version collector returns.
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"python": "9.9.9",
|
"python": "9.9.9",
|
||||||
@@ -144,15 +147,8 @@ class TestClusterKeywords(unittest.TestCase):
|
|||||||
str(self.__run1), str(self.__run2),
|
str(self.__run1), str(self.__run2),
|
||||||
str(self.__output_dir), "--clusters", clusters]
|
str(self.__output_dir), "--clusters", clusters]
|
||||||
argv.extend(extra_args or [])
|
argv.extend(extra_args or [])
|
||||||
fake: Any = self.__fake_encode(
|
|
||||||
vectors if vectors is not None
|
|
||||||
else self.__two_cluster_vectors())
|
|
||||||
stderr: io.StringIO = io.StringIO()
|
stderr: io.StringIO = io.StringIO()
|
||||||
with mock.patch.object(
|
with self.__fake_environment(vectors), \
|
||||||
cluster_keywords, "encode_keywords", fake), \
|
|
||||||
mock.patch.object(
|
|
||||||
cluster_keywords, "collect_versions",
|
|
||||||
return_value=self.__fake_versions()), \
|
|
||||||
redirect_stderr(stderr):
|
redirect_stderr(stderr):
|
||||||
status: int = cluster_keywords.main(argv)
|
status: int = cluster_keywords.main(argv)
|
||||||
return status, stderr.getvalue()
|
return status, stderr.getvalue()
|
||||||
@@ -449,7 +445,8 @@ class TestClusterKeywords(unittest.TestCase):
|
|||||||
|
|
||||||
def test_duplicate_extra_keyword_rejected(self) -> None:
|
def test_duplicate_extra_keyword_rejected(self) -> None:
|
||||||
"""Test that repeating the same ``--extra-keyword`` value
|
"""Test that repeating the same ``--extra-keyword`` value
|
||||||
fails the run without writing any output file."""
|
fails the run without writing the coding keyword set JSON
|
||||||
|
file or the run metadata JSON file."""
|
||||||
self.__write_output(self.__run1, [
|
self.__write_output(self.__run1, [
|
||||||
{"id": "song-1", "text": json.dumps(
|
{"id": "song-1", "text": json.dumps(
|
||||||
{"a-left": 1, "a-center": 1, "a-right": 1})},
|
{"a-left": 1, "a-center": 1, "a-right": 1})},
|
||||||
@@ -465,17 +462,14 @@ class TestClusterKeywords(unittest.TestCase):
|
|||||||
"--extra-keyword", "zzz-extra"])
|
"--extra-keyword", "zzz-extra"])
|
||||||
self.assertEqual(status, 1)
|
self.assertEqual(status, 1)
|
||||||
self.assertIn("zzz-extra", stderr)
|
self.assertIn("zzz-extra", stderr)
|
||||||
self.assertFalse(self.__source_keywords_txt.exists())
|
|
||||||
self.assertFalse(self.__result_groups_csv.exists())
|
|
||||||
self.assertFalse(self.__result_keywords_txt.exists())
|
|
||||||
self.assertFalse(self.__keywords_to_merge_json.exists())
|
self.assertFalse(self.__keywords_to_merge_json.exists())
|
||||||
self.assertFalse(self.__meta_json.exists())
|
self.assertFalse(self.__meta_json.exists())
|
||||||
|
|
||||||
def test_extra_keyword_duplicating_group_name_rejected(
|
def test_extra_keyword_duplicating_group_name_rejected(
|
||||||
self) -> None:
|
self) -> None:
|
||||||
"""Test that an ``--extra-keyword`` matching a clustered
|
"""Test that an ``--extra-keyword`` matching a clustered
|
||||||
group name fails the run without writing any output
|
group name fails the run without writing the coding
|
||||||
file."""
|
keyword set JSON file or the run metadata JSON file."""
|
||||||
self.__write_output(self.__run1, [
|
self.__write_output(self.__run1, [
|
||||||
{"id": "song-1", "text": json.dumps(
|
{"id": "song-1", "text": json.dumps(
|
||||||
{"a-left": 1, "a-center": 1, "a-right": 1})},
|
{"a-left": 1, "a-center": 1, "a-right": 1})},
|
||||||
@@ -490,9 +484,6 @@ class TestClusterKeywords(unittest.TestCase):
|
|||||||
extra_args=["--extra-keyword", "a-center"])
|
extra_args=["--extra-keyword", "a-center"])
|
||||||
self.assertEqual(status, 1)
|
self.assertEqual(status, 1)
|
||||||
self.assertIn("a-center", stderr)
|
self.assertIn("a-center", stderr)
|
||||||
self.assertFalse(self.__source_keywords_txt.exists())
|
|
||||||
self.assertFalse(self.__result_groups_csv.exists())
|
|
||||||
self.assertFalse(self.__result_keywords_txt.exists())
|
|
||||||
self.assertFalse(self.__keywords_to_merge_json.exists())
|
self.assertFalse(self.__keywords_to_merge_json.exists())
|
||||||
self.assertFalse(self.__meta_json.exists())
|
self.assertFalse(self.__meta_json.exists())
|
||||||
|
|
||||||
@@ -516,7 +507,7 @@ class TestClusterKeywords(unittest.TestCase):
|
|||||||
"embedding", "clustering", "extra_keywords",
|
"embedding", "clustering", "extra_keywords",
|
||||||
"keyword_count", "versions"})
|
"keyword_count", "versions"})
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
meta["script_version"], cluster_keywords.SCRIPT_VERSION)
|
meta["script_version"], "cluster_keywords.py 1.0.0")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
meta["versions"], self.__fake_versions())
|
meta["versions"], self.__fake_versions())
|
||||||
|
|
||||||
@@ -597,3 +588,72 @@ class TestClusterKeywords(unittest.TestCase):
|
|||||||
with self.assertRaises(SystemExit) as context:
|
with self.assertRaises(SystemExit) as context:
|
||||||
cluster_keywords.parse_args(argv)
|
cluster_keywords.parse_args(argv)
|
||||||
self.assertNotEqual(context.exception.code, 0)
|
self.assertNotEqual(context.exception.code, 0)
|
||||||
|
|
||||||
|
def test_builder_failure_raises_cluster_error(self) -> None:
|
||||||
|
"""Test that a builder reports its own failure as a
|
||||||
|
``ClusterError``, writing no file of its own."""
|
||||||
|
self.__write_two_clusters()
|
||||||
|
args: argparse.Namespace = self.__parse_args()
|
||||||
|
self.__output_dir.mkdir()
|
||||||
|
with self.__fake_environment():
|
||||||
|
source: cluster_keywords.PooledKeywords \
|
||||||
|
= cluster_keywords.KeywordPooler(
|
||||||
|
args.run_dir_1, args.run_dir_2,
|
||||||
|
self.__output_dir).run()
|
||||||
|
groups: cluster_keywords.KeywordGroups \
|
||||||
|
= cluster_keywords.KeywordClusterer(
|
||||||
|
source, args.model, args.revision,
|
||||||
|
args.clusters, self.__output_dir).run()
|
||||||
|
with self.assertRaises(
|
||||||
|
cluster_keywords.ClusterError) as context:
|
||||||
|
cluster_keywords.KeywordsToMergeBuilder(
|
||||||
|
groups, ["a-center"],
|
||||||
|
self.__output_dir).run()
|
||||||
|
self.assertIn("a-center", str(context.exception))
|
||||||
|
self.assertFalse(self.__keywords_to_merge_json.exists())
|
||||||
|
|
||||||
|
def __write_two_clusters(self) -> None:
|
||||||
|
"""Write the two runs' outputs of the two-cluster fixture.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.__write_output(self.__run1, [
|
||||||
|
{"id": "song-1", "text": json.dumps(
|
||||||
|
{"a-left": 1, "a-center": 1, "a-right": 1})},
|
||||||
|
])
|
||||||
|
self.__write_output(self.__run2, [
|
||||||
|
{"id": "song-2", "text": json.dumps(
|
||||||
|
{"b-north": 1, "b-middle": 1, "b-south": 1})},
|
||||||
|
])
|
||||||
|
|
||||||
|
def __parse_args(self) -> argparse.Namespace:
|
||||||
|
"""Parse a two-cluster command line over the fixture.
|
||||||
|
|
||||||
|
:return: The parsed arguments.
|
||||||
|
"""
|
||||||
|
return cluster_keywords.parse_args([
|
||||||
|
str(self.__run1), str(self.__run2),
|
||||||
|
str(self.__output_dir), "--clusters", "2"])
|
||||||
|
|
||||||
|
def __fake_environment(
|
||||||
|
self, vectors: Vectors | None = None) -> ExitStack:
|
||||||
|
"""Build the fake encoder and version collector context.
|
||||||
|
|
||||||
|
:param vectors: The fixed embedding to encode with; the
|
||||||
|
two-cluster fixture is used when None.
|
||||||
|
:return: A context manager patching the clusterer's
|
||||||
|
encoder with the fixed embedding and the metadata
|
||||||
|
builder's version collector with a fixed mapping.
|
||||||
|
"""
|
||||||
|
fake: Any = self.__fake_encode(
|
||||||
|
vectors if vectors is not None
|
||||||
|
else self.__two_cluster_vectors())
|
||||||
|
stack: ExitStack = ExitStack()
|
||||||
|
stack.enter_context(mock.patch.object(
|
||||||
|
cluster_keywords.KeywordClusterer,
|
||||||
|
"_KeywordClusterer__encode", staticmethod(fake)))
|
||||||
|
stack.enter_context(mock.patch.object(
|
||||||
|
cluster_keywords.MetaBuilder,
|
||||||
|
"_MetaBuilder__collect_versions",
|
||||||
|
staticmethod(self.__fake_versions)))
|
||||||
|
return stack
|
||||||
|
|||||||
Reference in New Issue
Block a user