← Back to list

Geek Out Time: Gradient Boosted Trees(GBT) Using a Hallucination Risk Playground

Gradient Boosted Trees (GBT) are one of those models everyone uses, and u probably know the story: sequential trees, correcting residuals…

Nedved Yang in The Constellar Digital&Technology Blog · 2026-02-19 15:34 · 2 claps · 7.1 min read paywalled
#gradient-boosting #hallucinations #machine-learning #google-colab
Open on Medium ↗
Wiki topics: SAF · Safety & Alignment ML · Machine Learning EDU · Education & Learning

Geek Out Time: Gradient Boosted Trees(GBT) Using a Hallucination Risk Playground

Gradient Boosted Trees (GBT) are one of those models everyone uses, and u probably know the story: sequential trees, correcting residuals, learning rate, depth trade-offs. This week’s geek-out was about playing with GBT in a modern setting — a synthetic “LLM hallucination risk” dataset. Not because I needed a production hallucination detector. In practice, GBT rarely shows up for that problem. The field has moved toward LLM-as-judge, semantic entropy, and RAG grounding checks — approaches that are more auditable and defensible in regulated environments. But that wasn’t the point. The LLM framing was just a vehicle to construct a dataset with the kind of structure that makes boosting interesting: overlapping distributions, label noise, nonlinear boundaries that no single decision rule can cleanly separate. A modern-feeling playground for an old favourite.

Problem Setup: Turning LLM Behavior into Tabular Features

Imagine sampling the same LLM prompt five times. Well, u can use the real LLM, like DeepSeek, to generate. But here I simplify it…

From those five answers, we compute observable signals:

  • How often the answers agree (majority_frac)
  • How spread out they are (answer_entropy)
  • Number of distinct answers
  • Hedging word count
  • Refusal count
  • Average answer length
  • Fraction of short answers

We then label each prompt as:

  • risk = 0 (low hallucination risk)
  • risk = 1 (high hallucination risk)

To make it realistic:

  • Feature distributions overlap heavily
  • 10% of labels are flipped (simulating messy real-world data)

Here is the full code that generates the dataset and trains the model.

Code

# Hallucination Risk Scoring with Gradient Boosting

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import roc_auc_score, accuracy_score, classification_report
np.random.seed(42)
# -------------------------
# Step 1: Synthetic dataset
# -------------------------
N = 600
n_low = N // 2
low_risk = pd.DataFrame({
    "majority_frac":     np.random.beta(4, 3, n_low),
    "answer_entropy":    np.random.beta(3, 4, n_low),
    "unique_answers":    np.random.randint(1, 5, n_low),
    "hedge_hits":        np.random.poisson(1.0, n_low),
    "refusal_hits":      np.random.poisson(0.5, n_low),
    "avg_answer_len":    np.random.normal(25, 15, n_low).clip(3, 80),
    "short_answer_frac": np.random.beta(4, 4, n_low),
    "risk": 0,
})
high_risk = pd.DataFrame({
    "majority_frac":     np.random.beta(3, 4, N - n_low),
    "answer_entropy":    np.random.beta(4, 3, N - n_low),
    "unique_answers":    np.random.randint(2, 6, N - n_low),
    "hedge_hits":        np.random.poisson(1.5, N - n_low),
    "refusal_hits":      np.random.poisson(0.8, N - n_low),
    "avg_answer_len":    np.random.normal(38, 18, N - n_low).clip(5, 120),
    "short_answer_frac": np.random.beta(3, 4, N - n_low),
    "risk": 1,
})
df = pd.concat([low_risk, high_risk]).sample(frac=1, random_state=42).reset_index(drop=True)
# Add 10% noise
noise_idx = df.sample(frac=0.10, random_state=7).index
df.loc[noise_idx, "risk"] = 1 - df.loc[noise_idx, "risk"]
print("Dataset:", len(df), "rows")
print(df["risk"].value_counts().to_string())

Dataset Overview

Output:

Dataset: 600 rows
risk
1    306
0    294

The classes are nearly balanced.

More importantly, the features overlap heavily between low and high risk. There is no trivial linear boundary. This is exactly where boosting becomes interesting.

Training the Gradient Boosted Model

features = [
    "majority_frac", "answer_entropy", "unique_answers",
    "hedge_hits", "refusal_hits", "avg_answer_len", "short_answer_frac"
]

X = df[features]
y = df["risk"]
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, stratify=y, random_state=42
)
gbt = GradientBoostingClassifier(
    n_estimators=200,
    learning_rate=0.05,
    max_depth=3,
    subsample=0.8,
    random_state=42,
)
gbt.fit(X_train, y_train)
proba = gbt.predict_proba(X_test)[:, 1]
pred  = (proba >= 0.5).astype(int)
print("\nAccuracy:", round(accuracy_score(y_test, pred), 3))
print("AUC:     ", round(roc_auc_score(y_test, proba), 3))
print(classification_report(y_test, pred, target_names=["Low Risk", "High Risk"]))

Model Performance

Output:

Accuracy: 0.773
AUC:      0.854

Accuracy of 77% is respectable, especially with label noise.

The more important metric is AUC = 0.854.

AUC measures ranking quality. An AUC of 0.854 means the model correctly ranks high-risk prompts above low-risk prompts roughly 85% of the time.

That tells us boosting successfully extracted nonlinear structure from overlapping distributions.

What Boosting Is Actually Doing

Gradient boosting works by sequentially adding shallow trees.

Each tree:

  • Looks at current residual errors
  • Learns a small correction
  • Gets added to the ensemble with a scaled contribution

With:

  • learning_rate=0.05
  • max_depth=3
  • 200 trees

We’re effectively stacking many small decision rules instead of one complex tree. This gives us flexibility without immediately overfitting. But does it overfit eventually? Let’s see.

Overfitting: Varying Tree Depth

depths = [1, 2, 3, 4, 5, 6, 8]
d_train, d_test = [], []

for d in depths:
    m = GradientBoostingClassifier(
        n_estimators=200,
        learning_rate=0.05,
        max_depth=d,
        random_state=42,
    )
    m.fit(X_train, y_train)
    d_train.append(roc_auc_score(y_train, m.predict_proba(X_train)[:, 1]))
    d_test.append(roc_auc_score(y_test,  m.predict_proba(X_test)[:, 1]))
plt.figure(figsize=(7,4))
plt.plot(depths, d_train, marker="o", label="Train AUC")
plt.plot(depths, d_test,  marker="s", label="Test AUC")
plt.xlabel("max_depth")
plt.ylabel("AUC")
plt.title("Deeper Trees Overfit")
plt.legend()
plt.show()

As depth increases:

  • Train AUC steadily rises
  • Test AUC peaks around depth=3
  • Then begins to decline

This is classic overfitting. Boosting doesn’t eliminate variance. It just allows you to manage it through shallow trees and shrinkage. Depth=3 turns out to be the sweet spot here.

Feature Importance

imp = pd.Series(gbt.feature_importances_, index=features).sort_values()
plt.figure(figsize=(7,4))
imp.plot(kind="barh")
plt.title("Feature Importance")
plt.show()

As expected, agreement-related features dominate:

  • majority_frac
  • answer_entropy
  • unique_answers

But the key insight is interaction. Boosting does not simply learn “high entropy = high risk.”

It learns layered rules such as:

  • If agreement is weak AND entropy is high → strong risk
  • If agreement is moderate BUT answer is short and clean → maybe safe
  • If hedging appears AND answers are long → risk increases

These nonlinear combinations are exactly why boosted trees are so powerful on tabular data.

Scoring New Prompts

def score_prompt(label, **kwargs):
    x = pd.DataFrame([kwargs])
    prob = float(gbt.predict_proba(x)[0, 1])
    if prob >= 0.6:
        verdict = "HIGH RISK"
    elif prob <= 0.4:
        verdict = "low risk"
    else:
        verdict = "uncertain"
    print(f"  {label}")
    print(f"  → risk score: {prob:.3f}  ({verdict})\n")

outputs:

Confident factual (e.g. capital of France)
→ risk score: 0.054  (low risk)

Unanswerable trap (e.g. secret Coca-Cola recipe)
→ risk score: 0.947  (HIGH RISK)
Borderline (e.g. recent news event)
→ risk score: 0.436  (uncertain)

Here’s an expanded version:

Scoring New Prompts

def score_prompt(label, **kwargs):
    x = pd.DataFrame([kwargs])
    prob = float(gbt.predict_proba(x)[0, 1])
    if prob >= 0.6:
        verdict = "HIGH RISK"
    elif prob <= 0.4:
        verdict = "low risk"
    else:
        verdict = "uncertain"
    print(f"  {label}")
    print(f"  → risk score: {prob:.3f}  ({verdict})\n")
Confident factual (e.g. capital of France)
→ risk score: 0.054  (low risk)

Output,

  Confident factual (e.g. capital of France)
  → risk score: 0.054  (low risk)

  Unanswerable trap (e.g. secret Coca-Cola recipe)
  → risk score: 0.947  (HIGH RISK)

  Borderline (e.g. recent news event)
  → risk score: 0.436  (uncertain)

The important part is not the labels. It’s the smooth probability surface. GBT produces calibrated risk scores, not rigid yes/no splits.

Three things stand out from these outputs.

The confident factual case scores 0.054 , not zero. That small residual reflects the label noise we deliberately injected into training. The model has seen enough cases where consistent, short answers were still wrong that it doesn’t fully trust even the clearest signals. That’s appropriate. A score of exactly zero would suggest overconfidence.

The unanswerable trap scores 0.947,not one. Same logic in reverse. The model has seen uncertain-looking responses that happened to be correct. It hedges slightly even on the most obvious high-risk case. Again, that’s the right behaviour for a probabilistic classifier.

The borderline case at 0.436 is the most interesting. We deliberately constructed its features to sit in the overlap zone between the two classes — moderate agreement, some hedging, medium-length answers. The model returns 0.436 and labels it “uncertain”, which is the honest answer. It isn’t close enough to either class to commit. Notice we didn’t use a single threshold at 0.5-we carved out a band from 0.4 to 0.6 and called it uncertain. That design choice matters.

In a real system, that uncertain band is where the interesting decisions happen. A score of 0.054 you can auto-approve. A score of 0.947 you can auto-flag. But 0.436 is the case you’d want to route to a human reviewer, add a confidence caveat to, or trigger a secondary verification step on. The value of a calibrated probability score is precisely that it tells you not just what the model thinks, but how much to trust that opinion and the two are not always the same thing…

Closing Thoughts

This experiment wasn’t about building a real hallucination detector. It was about pushing Gradient Boosted Trees around in a modern-feeling setting and watching how they behave.

Key takeaways:

  • Boosting handles overlapping distributions well.
  • Tree depth directly controls variance.
  • Learning rate stabilizes training.
  • Sequential weak learners can outperform a single deep tree.
  • Nonlinear feature interactions are captured naturally.

The LLM framing made it interesting. But the real lesson was this: when you actually play with boosting — vary depth, change learning rate, inject noise — you start to feel how it learns. That feeling is hard to get from reading about it.

There’s a difference between knowing that deeper trees overfit and watching your train AUC climb to 0.99 while your test AUC quietly drops. There’s a difference between understanding that learning rate controls shrinkage and seeing how a rate of 0.01 needs five times as many trees to reach the same place as 0.1. Theory tells you the direction. Experimentation tells you the magnitude.

The synthetic dataset was a deliberate choice for exactly this reason. Real datasets come with noise you didn’t put there, class imbalances you hv to explain, and stakeholders asking why the model got a specific case wrong. A synthetic dataset gives you a clean laboratory — you control what’s hard, you know where the noise came from, and when something behaves unexpectedly you can trace it back to a single parameter change.

GBT is one of the most reliable workhorses in applied ML and it shows up in production across finance, healthcare, logistics, and fraud detection precisely because it handles messy real-world tabular data better than almost anything else. But reliable doesn’t mean magical. It has failure modes, and the only way to know them is to find them urself.

The hallucination framing was useful for this experiment, but worth closing the loop on: in a real system, u wouldn’t build this. If u r already sampling the same prompt five times to compute consistency features, u hv spent more on inference than the classifier is worth. Production hallucination detection today looks more like RAG grounding checks, verifying whether the model’s answer is supported by retrieved source documents or LLM-as-judge, where a second model evaluates the first. GBT’s role, if any, would be as a lightweight layer on top of signals u hv already computed for other reasons, not as the primary detector. The experiment was never trying to compete with that. It was using the problem’s structure, not solving the problem.

Happy experiment and hv fun.


메타데이터
post_id
8eb644be9513
slug
geek-out-time-gradient-boosted-trees-gbt-using-a-hallucination-risk-playground-8eb644be9513
url
https://medium.com/the-constellar-digital-technology-blog/geek-out-time-gradient-boosted-trees-gbt-using-a-hallucination-risk-playground-8eb644be9513
canonical_url
https://medium.com/the-constellar-digital-technology-blog/geek-out-time-gradient-boosted-trees-gbt-using-a-hallucination-risk-playground-8eb644be9513
author_url
https://medium.com/@nedvedyang
status
ok
fetched_at
2026-06-16 19:09:56