Skip to content

feat(ensemble): default verdict is Binoculars-driven (fixes the 71% non-native FPR) - #6

Merged
satyamshivam13 merged 2 commits into
mainfrom
feat/ensemble-binoculars-default
Jul 13, 2026
Merged

feat(ensemble): default verdict is Binoculars-driven (fixes the 71% non-native FPR)#6
satyamshivam13 merged 2 commits into
mainfrom
feat/ensemble-binoculars-default

Conversation

@satyamshivam13

@satyamshivam13 satyamshivam13 commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Acts on the fairness finding from #5: the GPT-2-weighted ensemble flagged 71% of non-native English writers as AI. This makes the ensemble's default verdict Binoculars-driven — the signal that is both the most accurate and the fairest.

Change

  • EnsembleConfig default weights: binoculars=1.0, gpt2=0.0, nltk=0.0, roberta=0.0.
  • GPT-2 and NLTK still run and their sub-scores display for transparency, but they contribute 0 to the fused verdict. The verdict now inherits Binoculars' fairness (5.5% non-native) instead of GPT-2's bias (71%).
  • method_name → "Ensemble (Binoculars-weighted)"; app copy + narrative explain that the verdict is Binoculars-only and why (link to docs/benchmarks/FAIRNESS.md).
  • RoBERTa stays unloaded (weight 0); the ensemble now also loads distilgpt2 (Binoculars' performer).

Why keep GPT-2/NLTK running at weight 0?

Transparency — the ensemble's purpose is a multi-signal view. Users still see what GPT-2 and NLTK "thought", clearly marked as non-deciding. Only the verdict is gated to the fair signal.

Tests

  • Mocked ensemble tests now also mock Binoculars (the default runs it).
  • Fusion-mechanics tests opt into the legacy GPT-2/NLTK weighting explicitly (they test the math, not the policy).
  • New test: the fused score equals the Binoculars probability by default, regardless of GPT-2/NLTK perplexities.
  • Full fast suite: 236 passed, 85% coverage, lint clean.

Validation in progress

An end-to-end per-population FPR re-run on the new default ensemble is running; expected to drop from 27%/71% to ≈ Binoculars (1% / 5.5%). I'll post the confirmed numbers here and add the report to docs/benchmarks/ before this merges.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Changed
    • The default Ensemble verdict is now driven by Binoculars for improved fairness.
    • GPT-2 and NLTK scores remain available as transparency sub-scores but no longer influence the default verdict.
    • Analysis results now identify the method as “Ensemble (Binoculars-weighted)” and display verdict-weight details.
    • Updated in-app explanations and documentation describe the new behavior and fairness findings.

The per-population fairness evaluation showed the GPT-2-weighted ensemble flagged
71% of non-native English writers as AI (it inherited GPT-2's bias), vs 5.5% for
Binoculars. Change the default so the ensemble VERDICT is driven entirely by
Binoculars, which is both the most accurate and the fairest signal.

- EnsembleConfig default weights: binoculars=1.0, gpt2=0.0, nltk=0.0, roberta=0.0.
- GPT-2 and NLTK still run and their sub-scores display for transparency, but
  contribute 0 to the fused verdict. RoBERTa stays unloaded (weight 0).
- method_name -> "Ensemble (Binoculars-weighted)"; explanation and the ensemble
  app copy updated to state the verdict is Binoculars-only and why.
- ensemble.py warms the Binoculars models.

Tests updated: mocked ensemble tests now also mock Binoculars (the default runs
it); fusion-mechanics tests opt into the legacy GPT-2/NLTK weighting explicitly;
new test asserts the fused score equals the Binoculars probability by default.

Full fast suite: 236 passed, 85% coverage, lint clean. An end-to-end FPR
re-run on the new default is in progress and will be added to docs/benchmarks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@satyamshivam13, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 32 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: caa53ac0-b313-442d-8a40-c2aa28b26c00

📥 Commits

Reviewing files that changed from the base of the PR and between 1c0782c and 5d1d0b0.

📒 Files selected for processing (2)
  • docs/benchmarks/FAIRNESS.md
  • docs/benchmarks/fpr_ensemble.json
📝 Walkthrough

Walkthrough

The ensemble’s default verdict is now driven entirely by Binoculars. GPT-2 and NLTK continue to run for transparency, while labels, explanations, startup behavior, tests, and documentation reflect the new weighting.

Changes

Ensemble default behavior

Layer / File(s) Summary
Binoculars fusion contract
src/config/settings.py, src/analyzers/ensemble_analyzer.py
Default weights now use Binoculars exclusively, and analysis labels and explanations identify the Binoculars-weighted verdict.
Runtime and documentation updates
ensemble.py, README.md, CHANGELOG.md
Startup warms Binoculars when enabled, and user-facing documentation describes the fairness-focused default.
Fusion and serialization coverage
tests/test_ensemble_analyzer.py, tests/test_ensemble_weighted_fusion.py
Tests mock Binoculars, verify updated serialization and lazy loading, validate default verdict behavior, and explicitly configure legacy blend tests.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main change: the ensemble default verdict now comes from Binoculars, with fairness motivation noted.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ensemble-binoculars-default

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
tests/test_ensemble_weighted_fusion.py (1)

94-99: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use the name-based score lookup in this test.

The suite now protects score reordering, but this assertion still reads combined.scores[0]. Retrieve analyzer.ENSEMBLE_SCORE_NAME with get_score() so the regression remains valid if score order changes.

Proposed adjustment
-    assert abs(combined.scores[0].value - 0.2) < 1e-6
+    primary = combined.get_score(analyzer.ENSEMBLE_SCORE_NAME)
+    assert primary is not None
+    assert abs(primary.value - 0.2) < 1e-6
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_ensemble_weighted_fusion.py` around lines 94 - 99, Update the
assertion in the weighted fusion test to retrieve the fused score by calling
combined.get_score(analyzer.ENSEMBLE_SCORE_NAME) instead of indexing
combined.scores[0]. Preserve the existing comparison against 0.2 and tolerance.
tests/test_ensemble_analyzer.py (1)

76-83: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert the Binoculars signal through analyze().

The new fake is wired correctly, but current coverage mainly exercises _combine_results directly. Add an integration assertion that analyze() produces an ensemble score equal to the mocked Binoculars probability; otherwise a regression in the forwarding path could go undetected.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_ensemble_analyzer.py` around lines 76 - 83, Extend the ensemble
analyzer tests using _configure_mock_analyzers to call analyze() and assert that
the resulting ensemble score equals the mocked binoculars_ai probability. Keep
the existing _combine_results coverage, while specifically validating that
analyze() forwards the Binoculars signal into the final result.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@README.md`:
- Line 50: Update the Binoculars setup section in README.md to reflect that
Ensemble now enables Binoculars by default with weight_binoculars=1.0. Remove or
revise statements claiming it is excluded from the default ensemble or requires
a non-zero weight, while preserving accurate usage guidance.

In `@src/analyzers/ensemble_analyzer.py`:
- Around line 455-460: The verdict-weight formatting in analyze() directly
indexes the custom weights map for binoculars, causing legacy maps without that
key to raise KeyError. Replace the binoculars lookup with the existing
defaulting behavior, using weights.get("binoculars", 0.0), while preserving the
current display and fusion behavior.

---

Nitpick comments:
In `@tests/test_ensemble_analyzer.py`:
- Around line 76-83: Extend the ensemble analyzer tests using
_configure_mock_analyzers to call analyze() and assert that the resulting
ensemble score equals the mocked binoculars_ai probability. Keep the existing
_combine_results coverage, while specifically validating that analyze() forwards
the Binoculars signal into the final result.

In `@tests/test_ensemble_weighted_fusion.py`:
- Around line 94-99: Update the assertion in the weighted fusion test to
retrieve the fused score by calling
combined.get_score(analyzer.ENSEMBLE_SCORE_NAME) instead of indexing
combined.scores[0]. Preserve the existing comparison against 0.2 and tolerance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 399262d7-b9e8-4e3e-b59c-70cf403f15e2

📥 Commits

Reviewing files that changed from the base of the PR and between 2ce86a4 and 1c0782c.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • README.md
  • ensemble.py
  • src/analyzers/ensemble_analyzer.py
  • src/config/settings.py
  • tests/test_ensemble_analyzer.py
  • tests/test_ensemble_weighted_fusion.py

Comment thread README.md
| **NLTK** | `app.py` | `streamlit run app.py` | Statistical detection via NLTK n-gram language models (Brown corpus). No deep-learning model download. | Quick checks; low-resource machines; default starting point | `<1s` | `<1 GB` |
| **GPT-2** | `gpt2_app.py` | `streamlit run gpt2_app.py` | Perplexity-based detection using the GPT-2 transformer. | Users wanting a deep-learning signal | `2–5s` | `2–3 GB` |
| **Ensemble** | `ensemble.py` | `streamlit run ensemble.py` | Weighted fusion of GPT-2 + NLTK signals. **Inherits GPT-2's bias — see the fairness warning below; do not use for decisions about people.** | Experimentation only | `5–10s` | `2–3 GB` |
| **Ensemble** | `ensemble.py` | `streamlit run ensemble.py` | Verdict is **Binoculars-driven by default** (the fairest signal); GPT-2/NLTK sub-scores are shown but weight 0. | Multi-signal view with a fair default verdict | `5–10s` | `2–3 GB` |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update the stale Binoculars setup instructions.

This new row says Binoculars is enabled by default, but the later Binoculars section still says it is not in the default ensemble and must be given a non-zero weight. Update that section to describe the current weight_binoculars=1.0 default.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 50, Update the Binoculars setup section in README.md to
reflect that Ensemble now enables Binoculars by default with
weight_binoculars=1.0. Remove or revise statements claiming it is excluded from
the default ensemble or requires a non-zero weight, while preserving accurate
usage guidance.

Comment on lines +455 to +460
f"\n⚖️ **Verdict weights**: Binoculars {self.weights['binoculars'] * 100:.0f}%, "
f"GPT-2 {self.weights['gpt2'] * 100:.0f}%, "
f"NLTK {self.weights['nltk'] * 100:.0f}%."
f"NLTK {self.weights['nltk'] * 100:.0f}%, "
f"RoBERTa {self.weights['roberta'] * 100:.0f}%. "
f"GPT-2/NLTK scores are shown for transparency but do not drive the "
f"default verdict — see docs/benchmarks/FAIRNESS.md."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve compatibility with custom weight maps.

tests/test_ensemble_analyzer.py:test_custom_weights uses a legacy map without binoculars. If that map reaches analyze(), this new direct lookup raises KeyError; the broad handler then returns UNCERTAIN even though fusion succeeded. Use .get("binoculars", 0.0) consistently with the rest of the implementation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/analyzers/ensemble_analyzer.py` around lines 455 - 460, The
verdict-weight formatting in analyze() directly indexes the custom weights map
for binoculars, causing legacy maps without that key to raise KeyError. Replace
the binoculars lookup with the existing defaulting behavior, using
weights.get("binoculars", 0.0), while preserving the current display and fusion
behavior.

…lations

End-to-end re-run of the per-population FPR with the NEW default ensemble
(weight_binoculars=1.0). It collapses onto Binoculars' numbers, as intended:

  overall FPR      27.1%  ->  1.0%  [0.5%, 2.1%]
  non-native       71.4%  ->  5.5%
  native essays  10-24%   ->  0.0% (every native population)

fpr_ensemble.json now holds the current default's numbers; the FAIRNESS.md table
labels the old GPT-2-weighted blend as the cautionary baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@satyamshivam13

Copy link
Copy Markdown
Owner Author

Validated end-to-end. Re-ran the per-population FPR with the new default ensemble (785 human samples):

Old (GPT-2-weighted) New (Binoculars-weighted)
Non-native writers 71.4% 5.5%
Overall FPR 27.1% 1.0% [0.5%, 2.1%]
Every native-speaker population 10–24% 0.0%

The gating works: the ensemble now inherits Binoculars' fairness. Report updated in docs/benchmarks/.

@satyamshivam13
satyamshivam13 merged commit 20cb2ce into main Jul 13, 2026
9 of 10 checks passed
@satyamshivam13
satyamshivam13 deleted the feat/ensemble-binoculars-default branch July 13, 2026 13:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant