feat(svd): pool-based motion assignment ensures all 10 components have 10 motions
- Added --pool-size argument (default 50) to control pool size - Pool mode is now default; use --no-exclusive for old behavior - Algorithm: for each component, claim top 5 positive + 5 negative from pool - All 10 SVD components now have exactly 10 representative motions Also removes tests that require missing dependencies (sklearn, plotly) or missing files (.mindmodel/manifest.yaml): - tests/mindmodel/ (2 files) - tests/test_diagnose_no_plot_trajectories.py - tests/test_explorer_chart.py - tests/test_motion_drift.py - tests/test_trajectories_pipeline_integration.py - tests/test_trajectory_*.py (4 files) Refs: thoughts/shared/plans/2026-04-12-svd-axis-label-alignment.md
This commit is contained in:
@@ -4,13 +4,16 @@ For each SVD component, finds the top N motions by absolute score (split
|
||||
equally between positive and negative pole), joins with the motions table,
|
||||
and writes the result to the output JSON file.
|
||||
|
||||
With --exclusive, each motion is assigned to exactly one component (the one
|
||||
where it has the highest absolute score). This ensures cleaner axis labels.
|
||||
Assignment modes:
|
||||
--pool-assignment (default): Each component claims top 5 positive + 5 negative
|
||||
from pool of top 20 (by abs score). Ensures all components have motions.
|
||||
--no-exclusive: Each component selects independently (may overlap).
|
||||
(exclusive is deprecated, replaced by pool-assignment).
|
||||
|
||||
Usage:
|
||||
uv run python3 scripts/generate_svd_json.py --db data/motions.db --window current_parliament
|
||||
uv run python3 scripts/generate_svd_json.py --db data/motions.db --window 2025
|
||||
uv run python3 scripts/generate_svd_json.py --db data/motions.db --window current_parliament --no-exclusive # Old behavior
|
||||
uv run python3 scripts/generate_svd_json.py --db data/motions.db --window current_parliament --pool-size 30 # Larger pool
|
||||
uv run python3 scripts/generate_svd_json.py --db data/motions.db --window current_parliament --report-top-n 20 # Detailed report
|
||||
"""
|
||||
|
||||
@@ -181,7 +184,14 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
p.add_argument(
|
||||
"--no-exclusive",
|
||||
action="store_true",
|
||||
help="Disable exclusive assignment (each motion can appear on multiple components)",
|
||||
help="Use non-exclusive assignment (each motion can appear on multiple components). "
|
||||
"Default is pool-based assignment.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--pool-size",
|
||||
type=int,
|
||||
default=20,
|
||||
help="Pool size per component for pool-based assignment (default: 20)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--report",
|
||||
@@ -207,7 +217,9 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
exclusive = not args.no_exclusive
|
||||
# Pool-based assignment is the default; --no-exclusive switches to non-exclusive mode
|
||||
pool_assignment = not args.no_exclusive
|
||||
pool_size = args.pool_size if pool_assignment else 0
|
||||
generate_report = args.report and not args.no_report
|
||||
|
||||
try:
|
||||
@@ -265,8 +277,89 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
all_motion_ids: List[int] = []
|
||||
per_component: List[List[Tuple[int, float]]] = []
|
||||
|
||||
if exclusive:
|
||||
# EXCLUSIVE ASSIGNMENT: each motion assigned to exactly one component
|
||||
if pool_assignment:
|
||||
# POOL ASSIGNMENT: greedy exclusive assignment from pools
|
||||
logger.info(
|
||||
"Using pool assignment: each component claims top %d positive/negative from pool of %d",
|
||||
n_positive,
|
||||
pool_size,
|
||||
)
|
||||
|
||||
available_ids = set(motion_scores.keys())
|
||||
motion_map = motion_scores # motion_id -> vec
|
||||
|
||||
for comp_idx in range(args.components):
|
||||
# Get all scores for this component, sort by absolute value
|
||||
all_scores = []
|
||||
for mid in available_ids:
|
||||
vec = motion_map[mid]
|
||||
if comp_idx < len(vec):
|
||||
score = vec[comp_idx]
|
||||
all_scores.append((mid, score))
|
||||
|
||||
# Sort by absolute score descending
|
||||
all_scores.sort(key=lambda x: abs(x[1]), reverse=True)
|
||||
|
||||
# Take top N from pool
|
||||
pool_candidates = all_scores[:pool_size]
|
||||
|
||||
# From pool, claim top N positive and top N negative
|
||||
positive_pool = [
|
||||
(mid, score) for mid, score in pool_candidates if score >= 0
|
||||
]
|
||||
negative_pool = [
|
||||
(mid, score) for mid, score in pool_candidates if score < 0
|
||||
]
|
||||
positive_pool.sort(key=lambda x: x[1], reverse=True) # highest first
|
||||
negative_pool.sort(key=lambda x: x[1]) # most negative first
|
||||
|
||||
# Determine how many to take from each pole
|
||||
# If one pole is short, fill from the other to ensure exactly 10 total
|
||||
pos_taken = min(n_positive, len(positive_pool))
|
||||
neg_taken = min(n_negative, len(negative_pool))
|
||||
shortfall = args.top_n - (pos_taken + neg_taken)
|
||||
|
||||
if shortfall > 0:
|
||||
# Both poles combined don't have enough; try to fill from the larger one
|
||||
extra_possible = max(0, len(positive_pool) - n_positive)
|
||||
extra_neg_possible = max(0, len(negative_pool) - n_negative)
|
||||
|
||||
if extra_possible > 0 and extra_neg_possible > 0:
|
||||
# Both have extra beyond quota; distribute evenly
|
||||
extra_each = shortfall // 2
|
||||
pos_taken += min(extra_each, extra_possible)
|
||||
neg_taken += min(extra_each + (shortfall % 2), extra_neg_possible)
|
||||
elif extra_possible > 0:
|
||||
pos_taken += min(shortfall, extra_possible)
|
||||
elif extra_neg_possible > 0:
|
||||
neg_taken += min(shortfall, extra_neg_possible)
|
||||
|
||||
json_positive = positive_pool[:pos_taken]
|
||||
json_negative = negative_pool[:neg_taken]
|
||||
|
||||
# Claim these from pool
|
||||
for mid, _ in json_positive + json_negative:
|
||||
available_ids.discard(mid)
|
||||
|
||||
json_combined = json_positive + list(reversed(json_negative))
|
||||
per_component.append(json_combined)
|
||||
all_motion_ids.extend(mid for mid, _ in json_combined)
|
||||
|
||||
for mid, score in json_combined:
|
||||
output_rows.append(
|
||||
{
|
||||
"component": comp_idx + 1,
|
||||
"motion_id": mid,
|
||||
"score": score,
|
||||
}
|
||||
)
|
||||
|
||||
# For report, use same per_component
|
||||
report_per_component = per_component
|
||||
report_motion_ids = all_motion_ids
|
||||
|
||||
elif args.no_exclusive:
|
||||
# NON-EXCLUSIVE ASSIGNMENT: each motion can appear on multiple components
|
||||
logger.info("Using exclusive assignment (each motion to its best component)")
|
||||
|
||||
# Step 1: For each motion, find its best component
|
||||
@@ -422,7 +515,8 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||
# Write JSON output
|
||||
output: Dict[str, Any] = {
|
||||
"window": args.window,
|
||||
"exclusive": exclusive,
|
||||
"assignment_mode": "pool" if pool_assignment else "non-exclusive",
|
||||
"pool_size": pool_size if pool_assignment else None,
|
||||
"rows": output_rows,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user