Skip to content

Commit 59cae35

Browse files
committed
Fix K-means k divergence: preserve vote-encounter row order
## Summary - Fix K-means k divergence between Python and Clojure by preserving vote-encounter order for participant rows in the rating matrix - Python was using `natsorted()` (PID-numeric order) while Clojure's NamedMatrix preserves insertion order — different row ordering cascades into different first-k-distinct initialization seeds for group-level k-means - On vw: Python picked k=4 (wrong), Clojure picks k=2 — now both pick k=2 with identical cluster memberships ## Investigation findings The divergence chain: rating_mat row order → PCA projection order → base-cluster ID assignment → group k-means first-k-distinct init → different local optima → different silhouette landscape → different k. PCA components are identical (cosine similarity = 1.0), silhouette implementation matches, k-means algorithm matches — only the data ORDER feeding first-k-distinct differed. ## Changes - `conversation.py`: `update_votes()` preserves vote-encounter order for participant rows instead of `natsorted()` - `conversation.py`: `_apply_moderation()` preserves row order with list comprehension - Column (comment ID) ordering remains `natsorted` — doesn't affect clustering - Re-recorded vw cold-start blob and golden snapshots - Updated ordering tests, removed `test_group_clustering` xfail - Added `scripts/investigate_k_divergence.py` diagnostic tool ## Cold-start blob results | Dataset | Clj k | Py k | Match | |---------|-------|------|-------| | vw | 2 | 2 | exact (sizes [50,17]) | | biodiversity | 2 | 2 | exact (sizes [81,19]) | | bg2018 | 2 | 2 | close ([51,49] vs [52,48]) | | FLI | 2 | 3 | inherent PCA divergence (94.5% NaN, sil gap 0.001) | ## Test plan - [x] All 297 tests pass (0 failures, 58 xfailed) - [x] vw cold-start: k=2 exact match with Clojure blob - [x] biodiversity cold-start: k=2 exact match - [x] Ordering tests updated to expect encounter order - [ ] Re-record private dataset golden snapshots after stack rebase 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Squashed commits - Fix K-means k divergence: preserve vote-encounter order for participant rows - Update plan and journal: K-divergence investigation resolved - Remove investigation script (one-off diagnostic, not production code) - Rename k-divergence doc: investigation record, not a handoff - Update references to renamed investigation doc commit-id:4598a0a1
1 parent 7bf285c commit 59cae35

8 files changed

Lines changed: 1219 additions & 860 deletions

File tree

delphi/docs/CLJ-PARITY-FIXES-JOURNAL.md

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,7 @@ Every fix PR must now include blob comparison tests.
621621
Both use silhouette. The divergence comes from upstream PCA/clustering differences
622622
(sklearn SVD vs Clojure power iteration). This is independent of all repness fixes
623623
(D4-D11). Investigation planned off D15 branch. See
624-
`delphi/docs/HANDOFF_K_DIVERGENCE_INVESTIGATION.md`.
624+
`delphi/docs/INVESTIGATION_K_DIVERGENCE.md`.
625625

626626
**Key discovery: `n-trials` in Clojure blob = `S` (total seen, including passes),**
627627
not `A+D` (agrees + disagrees). Verified: `prop_test(11, 14)` = blob `p-test` for
@@ -662,6 +662,75 @@ adding vectorized blob tests at each stage.
662662

663663
---
664664

665+
## K-Divergence Investigation & Fix (2026-03-17/18)
666+
667+
### Branch: `jc/clj-parity-kmeans-k-divergence` (PR #2524, Stack 17/17)
668+
669+
### Investigation
670+
671+
Wrote `scripts/investigate_k_divergence.py` to isolate the source of divergence
672+
on vw (Python k=4, Clojure k=2). Systematic elimination:
673+
674+
1. **PCA components**: identical (cosine similarity = 1.000000) — ruled out
675+
2. **Silhouette implementation**: identical scores for both projection sets — ruled out
676+
3. **K-means initialization**: both use first-k-distinct — ruled out
677+
4. **Clojure blob injection**: injecting Clojure projections into Python clustering
678+
still gave k=4 — so it's not about projection values
679+
5. **Participant ordering**: **ROOT CAUSE FOUND** — Python sorted rows by PID via
680+
`natsorted()`, Clojure preserves vote-encounter order (NamedMatrix insertion order).
681+
Different row ordering → different first-k-distinct seeds → different local optima.
682+
683+
Verified Clojure ordering chain by reading `conversation.clj`, `named_matrix.clj`,
684+
`clusters.clj`: `filter-by-index` preserves original matrix row order, not
685+
set iteration order. The CSV first-appearance order `[2, 3, 4, 6, 8, ...]` matches
686+
the Clojure blob's base-cluster PID order exactly.
687+
688+
### Fix
689+
690+
- `conversation.py update_votes()`: replaced `natsorted(existing_rows.union(new_rows))`
691+
with first-appearance order tracking from `vote_updates`
692+
- `conversation.py _apply_moderation()`: replaced `natsorted()` with order-preserving
693+
list comprehension
694+
- Column ordering remains natsorted (doesn't affect clustering)
695+
696+
### Cold-start blob results
697+
698+
| Dataset | Clj k | Py k (before) | Py k (after) | Sizes match? |
699+
|---------|-------|---------------|--------------|--------------|
700+
| vw | 2 | 4 | **2** | [50,17] exact |
701+
| biodiversity | 2 | 2 | **2** | [81,19] exact |
702+
| bg2018 | 2 | 2 | **2** | close ([52,48] vs [51,49]) |
703+
| FLI | 2 | 3 | 3 | inherent PCA divergence |
704+
705+
FLI: 94.5% NaN sparsity, PCA |cos|≈0.9997 (not 1.0), silhouette gap 0.001. Not
706+
fixable without replicating Clojure's power iteration PCA. Low priority.
707+
708+
### Test results
709+
710+
- 297 passed, 0 failed, 6 skipped, 58 xfailed
711+
- Removed `test_group_clustering` xfail (now passes on cold-start blobs)
712+
- Added incremental-blob xfail (different in-conv from single-shot)
713+
- Updated 6 ordering tests (expect encounter order, not natsort)
714+
- Re-recorded vw cold-start blob and golden snapshots for vw + biodiversity
715+
716+
### Session 12 (2026-03-17/18)
717+
718+
- Created branch off D15, investigated k divergence across all 7 datasets
719+
- Re-recorded vw cold-start blob (confirmed k=2 is genuine, not generation artifact)
720+
- Found root cause: `natsorted()` on participant rows
721+
- Fixed `update_votes()` and `_apply_moderation()` to preserve encounter order
722+
- Rebased branch onto new D15 (other session had rebased the stack)
723+
- Inserted into stack at position 19/25, rebased D10→PR15 with `--onto`
724+
- Created PR #2524
725+
726+
### What's Next
727+
728+
1. Refactor D10-D1 branches (tests, code cleanup) before creating PRs for them.
729+
2. Re-record private dataset golden snapshots.
730+
3. FLI k divergence: accept or investigate Clojure power iteration PCA (low priority).
731+
732+
---
733+
665734
## TDD Discipline
666735

667736
**CRITICAL: For every fix, ALWAYS follow this order:**
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# K-Divergence Investigation — RESOLVED
2+
3+
## Problem (was)
4+
5+
After all cold-start-relevant formula fixes (D2-D15), Python and Clojure
6+
selected different k values on cold-start blobs. On vw: Python=4, Clojure=2.
7+
8+
## Root Cause: Participant Row Ordering
9+
10+
The k divergence was caused by **different participant ordering in the rating
11+
matrix**, which cascades through base-cluster IDs into group-level k-means
12+
initialization via first-k-distinct.
13+
14+
### The chain
15+
16+
```
17+
rating_mat row order
18+
→ PCA projection order
19+
→ base-cluster ID assignment (map-indexed on input rows)
20+
→ group-level k-means first-k-distinct init (first k base-cluster centers)
21+
→ different local optima → different silhouette scores → different k
22+
```
23+
24+
### Clojure ordering
25+
26+
Clojure's NamedMatrix preserves **insertion order** (backed by
27+
`java.util.Vector`). When `rowname-subset` filters to `in-conv` participants,
28+
`filter-by-index` (utils.clj:128-133) preserves the **original matrix row
29+
order** (iterates source, checks membership in filter set). So the base-cluster
30+
ordering is the vote-encounter order of participants in the rating matrix.
31+
32+
### Python ordering (before fix)
33+
34+
Python used `natsorted()` (conversation.py:232) to sort rating matrix rows
35+
by PID. This gave ascending PID order `[1, 2, 3, 4, 5, ...]` instead of
36+
the vote-encounter order `[2, 3, 4, 6, 8, ...]` that Clojure produces.
37+
38+
### Impact
39+
40+
With first-k-distinct initialization, different ordering → different initial
41+
centers → different k-means local optima → different silhouette landscape:
42+
43+
| k | Python (PID order) | Clojure (encounter order) |
44+
|---|-------------------|--------------------------|
45+
| 2 | sil=0.457 | **sil=0.487 (wins)** |
46+
| 3 | sil=0.481 | sil=0.329 |
47+
| 4 | **sil=0.508 (wins)** | sil=0.362 |
48+
49+
## Fix
50+
51+
Changed `update_votes()` and `_apply_moderation()` to preserve vote-encounter
52+
order for participant rows instead of natsort:
53+
54+
1. `update_votes()`: track first-appearance order from `vote_updates`, append
55+
new PIDs in encounter order (not `natsorted`)
56+
2. `_apply_moderation()`: filter `raw_rating_mat.index` preserving order
57+
(list comprehension instead of `natsorted`)
58+
59+
Column (comment ID) ordering remains `natsorted` — column permutation doesn't
60+
affect PCA eigenvalues/vectors, only reorders component loadings.
61+
62+
## Results after fix
63+
64+
| Dataset | CS blob | Clj k | Py k | Sizes match? |
65+
|---------|---------|-------|------|--------------|
66+
| vw || 2 | **2** | [50,17] exact |
67+
| biodiversity || 2 | **2** | [81,19] exact |
68+
| bg2018 || 2 | **2** | close ([51,49] vs [52,48]) |
69+
| FLI || 2 | 3 | **still diverges** |
70+
| engage | empty ||||
71+
| bg2050 | empty ||||
72+
| pakistan | empty ||||
73+
74+
### FLI: inherent PCA divergence (not fixable)
75+
76+
FLI has 94.5% NaN sparsity. The PCA components are nearly but not exactly
77+
identical (|cos|≈0.9997 vs 1.000000 for vw). This produces a silhouette
78+
landscape where k=2 and k=3 differ by only 0.001. The tiny PCA difference
79+
tips the balance. Injection test confirms: with Clojure projections injected,
80+
Python picks k=2. This is inherent to the PCA algorithm difference (sklearn
81+
full SVD vs Clojure power iteration) and not fixable without replicating
82+
Clojure's PCA exactly.
83+
84+
## Investigation Findings (for the record)
85+
86+
### PCA is NOT the primary cause for most datasets
87+
88+
- vw: PCA components have cosine similarity = 1.000000 (identical!)
89+
- Projections are exactly negated (sign flip, irrelevant for clustering)
90+
- Silhouette scores are identical for both projection sets
91+
92+
### Silhouette implementation matches
93+
94+
- Both use (b-a)/max(a,b) formula, unweighted mean
95+
- Both compute on base-cluster centers (not raw participants)
96+
- Clojure's `weighted-mean` without weights = unweighted mean
97+
98+
### K-means initialization matches
99+
100+
- Both use first-k-distinct (Clojure: `init-clusters`, Python: `_get_first_k_distinct_centers`)
101+
- Both sort base clusters by ID
102+
- The only difference was the DATA ORDER feeding into first-k-distinct
103+
104+
## Files modified
105+
106+
- `delphi/polismath/conversation/conversation.py``update_votes()` and `_apply_moderation()`
107+
- `delphi/tests/test_conversation.py` — updated ordering tests
108+
- `delphi/tests/test_legacy_clojure_regression.py` — removed xfail on `test_group_clustering`
109+
110+
## Future investigation
111+
112+
- **FLI k divergence**: Could be resolved by implementing Clojure's power
113+
iteration PCA. Low priority — the silhouette gap is 0.001.
114+
- **Column ordering**: Currently natsorted, Clojure uses insertion order.
115+
Doesn't affect clustering but could affect other comparisons.
116+
- **Multiple k-means restarts**: Using k-means++ with n_init=10 finds the
117+
global optimum (k=4 for vw) regardless of ordering. This would be more
118+
robust than first-k-distinct but would NOT match Clojure.

delphi/docs/PLAN_DISCREPANCY_FIXES.md

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ This plan's "PR N" labels map to actual GitHub PRs as follows:
2222
| PR 3 (D9) | #2518 || Fix D9: z-score thresholds (one-tailed) |
2323
| PR 4 (D5) | #2448 | Stack 14/25 | Fix D5: proportion test formula |
2424
| PR 5 (D6) | #2449 | Stack 15/25 | Fix D6: two-proportion test pseudocounts |
25+
| (K-inv) | #2524 | Stack 17/17 | Fix K-means k divergence: preserve vote-encounter row order |
2526

2627
Future fix PRs will be appended to the stack as they're created.
2728

@@ -444,35 +445,29 @@ By this point, we should have good test coverage from all the per-discrepancy te
444445

445446
---
446447

447-
### Investigation: Cold-Start K Divergence (after D15, before D12)
448+
### K-Divergence Fix: Participant Row Ordering — **DONE** (PR #2524)
448449

449-
**Prerequisite**: All cold-start-relevant upstream fixes complete: D2/D2c/D2b (in-conv,
450-
vote counts, sort order), D15 (moderation handling). Note: D1 (PCA sign flips) only
451-
affects incremental updates — on cold start there are no previous components to align to.
450+
**Root cause found and fixed.** Python's `natsorted()` sorted rating matrix rows by
451+
PID, while Clojure's NamedMatrix preserves vote-encounter order (insertion order via
452+
`java.util.Vector`). Different row ordering cascades through base-cluster ID assignment
453+
into group-level k-means first-k-distinct initialization, producing different local
454+
optima and different silhouette landscapes.
452455

453-
After D15, the rating matrix construction, in-conv filtering, and PCA inputs should all
454-
match Clojure. Both implementations use silhouette for k-selection. Yet on vw, Python
455-
selects k=4 while Clojure selects k=2.
456+
**Fix**: `update_votes()` and `_apply_moderation()` now preserve vote-encounter order
457+
for participant rows instead of natsort. Column ordering remains natsorted (doesn't
458+
affect PCA eigenvalues/vectors).
456459

457-
**Investigation steps**:
460+
**Cold-start blob results**:
461+
- vw: k=2 exact match (was k=4), sizes [50,17] exact
462+
- biodiversity: k=2 exact match, sizes [81,19] exact
463+
- bg2018: k=2 match, sizes close ([52,48] vs [51,49])
464+
- FLI: k=3 vs k=2 — inherent PCA divergence (94.5% NaN sparsity, silhouette gap 0.001)
458465

459-
1. **PCA component comparison**: Feed the same rating matrix to both sklearn TruncatedSVD
460-
and a Python reimplementation of Clojure's power iteration. Quantify divergence
461-
(cosine similarity per component, Frobenius norm).
462-
2. **Projection comparison**: Inject Clojure blob's PCA components into Python's
463-
clustering path. Does k now match?
464-
3. **Base-cluster comparison**: Given the same projections, compare k-means centroids
465-
and member assignments. Check initialization (Clojure uses first-k-distinct centers
466-
from base clusters — does Python match?).
467-
4. **Silhouette score comparison**: Given the same base clusters, compare per-k
468-
silhouette scores. Are the scores close but the winner differs?
469-
5. **All datasets**: Run on all datasets with cold-start blobs, not just vw.
466+
**FLI residual divergence**: Not fixable without replicating Clojure's power iteration
467+
PCA. The silhouette landscape is essentially flat between k=2 and k=3, and any tiny PCA
468+
difference tips the balance. Low priority.
470469

471-
**Outcome**: Either (a) identify a fixable discrepancy that makes k match, or
472-
(b) document the inherent numerical divergence between sklearn SVD and Clojure
473-
power iteration, and establish tolerance bounds for k agreement in tests.
474-
475-
See `delphi/docs/HANDOFF_K_DIVERGENCE_INVESTIGATION.md` for detailed context.
470+
See `delphi/docs/INVESTIGATION_K_DIVERGENCE.md` for the full investigation.
476471

477472
---
478473

@@ -513,7 +508,7 @@ See `delphi/docs/HANDOFF_K_DIVERGENCE_INVESTIGATION.md` for detailed context.
513508
| D13 | Subgroup clustering ||| **Deferred** (unused) |
514509
| D14 | Large conv optimization ||| **Deferred** (Python fast enough) |
515510
| D15 | Moderation handling | PR 12 || **DONE**|
516-
| K-inv | Cold-start k divergence | (investigation) | | Branch off D15 (D2+D15 done, clustering independent of repness) |
511+
| K-inv | Cold-start k divergence (row ordering) | (after D15) | **#2524** | **DONE** ✓ (FLI residual: inherent PCA divergence) |
517512
| Replay | Replay infrastructure (A/B/C) ||| NOT BUILT — D3/D1 used synthetic tests only. Needed for incremental blob comparison. |
518513

519514
### Non-discrepancy PRs in the stack

delphi/polismath/conversation/conversation.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -220,15 +220,35 @@ def update_votes(self,
220220
# Step 4: Get new rows and columns by set difference
221221
logger.info(f"[{time.time() - start_time:.2f}s] Identifying new rows and columns...")
222222

223-
existing_rows = set(existing_rows)
223+
existing_rows_set = set(existing_rows)
224224
existing_cols = set(existing_cols)
225225

226-
new_rows = set(updates_df['row']) - existing_rows
226+
new_rows = set(updates_df['row']) - existing_rows_set
227227
new_cols = set(updates_df['col']) - existing_cols
228228

229-
# Natural sort: preserves types and sorts numerically when possible
230-
# Numbers are sorted numerically, alphanumeric strings use natural order (e.g., p1, p2, p10)
231-
all_rows = natsorted(existing_rows.union(new_rows))
229+
# Row order: preserve first-appearance order from votes.
230+
#
231+
# Clojure builds the rating matrix incrementally — each new participant
232+
# gets a row appended in the order they first appear in the vote stream
233+
# (conversation.clj, named_matrix.clj: NamedMatrix preserves insertion
234+
# order via IndexHash backed by java.util.Vector). The base-cluster IDs
235+
# are assigned by map-indexed on this row order, so the order directly
236+
# determines group-level k-means initialization via first-k-distinct.
237+
#
238+
# Using natsort (PID-numeric order) instead would change the k-means
239+
# seed points and produce different silhouette scores / different k.
240+
# See delphi/docs/INVESTIGATION_K_DIVERGENCE.md for the full
241+
# analysis showing this is the root cause of k divergence on vw.
242+
new_rows_ordered = []
243+
for pid, _, _ in vote_updates:
244+
if pid in new_rows and pid not in existing_rows_set:
245+
existing_rows_set.add(pid)
246+
new_rows_ordered.append(pid)
247+
all_rows = list(existing_rows) + new_rows_ordered
248+
249+
# Column order: natsort is fine — column permutation doesn't affect PCA
250+
# eigenvalues/vectors (only reorders the component loadings), so it has
251+
# no effect on clustering k.
232252
all_cols = natsorted(existing_cols.union(new_cols))
233253

234254
logger.info(f"[{time.time() - start_time:.2f}s] Found {len(new_rows)} new rows and {len(new_cols)} new columns")
@@ -304,8 +324,10 @@ def _apply_moderation(self) -> None:
304324
matrix structure so that tids, column indices, and dimensions
305325
match between Python and Clojure.
306326
"""
307-
# Filter out moderated participants (remove rows)
308-
keep_ptpts = natsorted(list(set(self.raw_rating_mat.index) - set(self.mod_out_ptpts)))
327+
# Filter out moderated participants (remove rows).
328+
# Preserve raw_rating_mat row order (vote encounter order) — see
329+
# update_votes() comment on why row order matters for Clojure parity.
330+
keep_ptpts = [p for p in self.raw_rating_mat.index if p not in self.mod_out_ptpts]
309331
self.rating_mat = self.raw_rating_mat.loc[keep_ptpts].copy()
310332

311333
# Zero out moderated-out comments (keep columns, set values to 0)

0 commit comments

Comments
 (0)