Files

104 lines
4.3 KiB
Python

"""Turn support conversations into numbers with Kai's answers, then fit a small regressor to labels."""
import gzip
import json
import pathlib
import tempfile
import urllib.request
import numpy as np
from hanzo_kai import Kai, Noul, Score, RetryPolicy
HARNESS = "https://raw.githubusercontent.com/hanzoai/benchmarks/039fbfe5fa5dcb1483407594d829fa822d78974a/decision/results/states.json.gz"
FEATURES = {
"upset": Noul(
instructions="The customer is upset.",
criteria={"true": "they sound angry, frustrated or anxious", "false": "they sound calm or satisfied"},
),
"leave": Noul(
instructions="The customer threatens to leave.",
criteria={
"true": "they mention cancelling, switching, a chargeback, a dispute or a bad review",
"false": "no such threat",
},
),
"again": Noul(
instructions="The problem has happened before.",
criteria={
"true": "they say it keeps happening, or was reported before and is still not fixed",
"false": "this is the first report",
},
),
"money": Noul(
instructions="The customer has lost money.",
criteria={
"true": "a wrong, duplicate or unexpected charge, or a refund that has not arrived",
"false": "no money is at stake",
},
),
"effort": Score(
instructions="How much effort has the customer spent on this problem?",
criteria=["almost none", "one message", "several messages or attempts", "repeated contacts over days"],
),
"tone": Score(
instructions="How does the customer's last message sound?",
criteria=["pleased", "neutral", "annoyed", "furious"],
),
}
def harness(suite: str) -> list:
"""The pinned harness's cases for one suite, each [state, questions, gold]."""
path = pathlib.Path(tempfile.gettempdir()) / "hanzo-kai-cookbook" / "states.json.gz"
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
urllib.request.urlretrieve(HARNESS, path)
with gzip.open(path) as file:
return json.load(file)[suite]
def value(answers, name: str) -> float:
"""A noul's P(yes) or a score's expected level."""
return answers.nouls[name].noul if name in answers.nouls else answers.scores[name].score
def ridge(x: np.ndarray, y: np.ndarray, train: np.ndarray, penalty: float = 1.0) -> tuple[np.ndarray, np.ndarray]:
"""Predictions for every row from a ridge fit on the training rows, and the standardized weights."""
mean, scale = x[train].mean(0), x[train].std(0) + 1e-9
z = (x - mean) / scale
weights = np.linalg.solve(
z[train].T @ z[train] + penalty * np.eye(x.shape[1]), z[train].T @ (y[train] - y[train].mean())
)
return y[train].mean() + z @ weights, weights
cases = [case for case in harness("typed_decisions") if case[2]["_wf"] == "customer_service"]
x, direct, y = [], [], []
with Kai(retry=RetryPolicy(max_retries=10)) as kai:
for state, questions, gold in cases:
answers = kai.decide(state=state, questions=FEATURES | {"churn": questions["churn_risk"]})
x.append([value(answers, name) for name in FEATURES])
direct.append(answers.scores["churn"].score)
y.append(gold["churn_risk"]["gold_score"]) # raters' mean level, 0 to 3
x, direct, y = np.array(x), np.array(direct)[:, None], np.array(y)
order = np.random.default_rng(7).permutation(len(y))
train, test = order[:60], order[60:]
print(f"{len(y)} conversations: {len(train)} to fit, {len(test)} to test; churn risk 0 to 3, mean {y.mean():.2f}\n")
print(f"{'predictor':34} test RMSE correlation")
fits = {
"mean of the training labels": np.full(len(y), y[train].mean()),
"Kai's churn score, as answered": direct[:, 0],
"Kai's churn score, refit": ridge(direct, y, train)[0],
"six Kai features": ridge(x, y, train)[0],
"six features and the churn score": ridge(np.hstack([x, direct]), y, train)[0],
}
for name, prediction in fits.items():
error = np.sqrt(np.mean((prediction[test] - y[test]) ** 2))
r = f"{np.corrcoef(prediction[test], y[test])[0, 1]:.2f}" if np.ptp(prediction[test]) else "-"
print(f"{name:34} {error:9.3f} {r:>11}")
print("\nweights of the six-feature fit, per standard deviation of each feature:")
print(" ".join(f"{name} {w:+.3f}" for name, w in zip(FEATURES, ridge(x, y, train)[1], strict=True)))