AI Safety and Ethics

Deploying responsibly

Responsible deployment is four concrete controls — abstain when unsure, limit the blast radius, watch what happens, and be able to switch it off.

On this page 8
  1. The short answer
  2. The analogy you have already lived
  3. The four controls, in order of value
  4. The picture
  5. Why "we tested it and it was accurate" is not enough
  6. What is honestly hard here
  7. Remember this
  8. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

The short answer

Responsible deployment means the system stays safe on the day the model is wrong.

The analogy you have already lived

Think of learning to swim. You did not start in the deep end. You started where you could stand, with somebody watching, near the edge.

Nobody arranged that because they doubted you. They arranged it because the cost of being wrong in deep water is different from the cost of being wrong in shallow water.

A model launch works the same way. Start shallow. Keep somebody watching. Stay near the edge until you have evidence.

The four controls, in order of value

Everything in this lesson reduces to four things a careful team builds. Each one is a few lines of code.

Let it say "I don't know". A model gives a confidence with every answer. When that confidence is low, do not answer — pass the case to a person. The experiment below shows this reaching perfect accuracy on the cases it does answer, by handing over three cases in ten.

Limit what it can touch. Ship to 1% of traffic before 100%. Give it read access before write access. Ask a human to approve anything irreversible.

Watch what actually happens. Not accuracy on your old test set. Live inputs, live outputs, complaint rates, and how many cases get escalated. Failures announce themselves in these numbers first.

Be able to switch it off. One flag, one person, no deploy required. Practise it once before you need it.

The picture

   request
      │
      ▼
   [ is the model switched on?     ] ── no ──► old rule-based path
      │ yes
      ▼
   [ within the rate budget?       ] ── no ──► old rule-based path
      │ yes
      ▼
   [ model answers, with a         ]
   [ confidence number attached    ]
      │
      ├── confident ─────► answer, and log it
      └── unsure    ─────► send to a human, and log it

Every box except the middle one is ordinary code. That is the point. Most of the safety in a deployed AI system is not in the model.

Why "we tested it and it was accurate" is not enough

Your test set is a photograph of the past. Deployment is the future, and the future differs in ways you did not sample.

New slang appears. A camera model changes. A festival shifts shopping behaviour. A competitor's outage sends you unfamiliar users.

None of that is a bug in your model. All of it changes what your model sees, and therefore how well it works. This is called drift — the world moving away from the data you trained on.

What is honestly hard here

Deciding when to switch it off is harder than building the switch.

Metrics wobble. Every dip looks like the start of a disaster on a small sample. Teams that pull the switch on every wobble stop being trusted, and teams that never pull it discover the problem from a news article.

Write the trigger down before launch: if the escalation rate crosses this line for this long, we roll back. Deciding under pressure, without a rule, goes badly.

Remember this

  • Let the model abstain when it is unsure, and route those cases to a person.
  • Roll out small first, with the smallest permissions that work.
  • Build the off switch on day one, and write down what triggers it.

What to learn next

Developer — Code and libraries.

Four controls, one file

Below: selective prediction (abstention), a guarded serving wrapper with a kill switch and a call budget, an audit log, and a canary comparison against a candidate model.

The dataset ships inside scikit-learn, so nothing downloads.

Setup

bash
pip install numpy scikit-learn

The whole deployment shell

guarded_serving.py
import numpy as np
from sklearn.datasets import load_digits
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split

digits = load_digits()                                  # bundled with scikit-learn
X, y = digits.data / 16.0, digits.target
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3, random_state=0, stratify=y)
model = LogisticRegression(max_iter=5000).fit(Xtr, ytr)

proba = model.predict_proba(Xte)
pred = proba.argmax(1)
conf = proba.max(1)

print("SELECTIVE PREDICTION: answer only when confident, send the rest to a human")
print(f"{'threshold':>10}{'coverage':>10}{'accuracy on answered':>22}{'sent to human':>15}")
for t in (0.00, 0.50, 0.80, 0.90, 0.99):
    take = conf >= t
    acc = (pred[take] == yte[take]).mean() if take.any() else float("nan")
    print(f"{t:>10.2f}{take.mean():>10.3f}{acc:>22.4f}{(~take).sum():>15}")

# ---------- a serving wrapper with the four controls every deployment needs ----------
class GuardedModel:
    def __init__(self, model, threshold=0.90, budget=3):
        self.model, self.threshold, self.budget = model, threshold, budget
        self.enabled = True          # 1. kill switch
        self.log = []                # 2. audit trail
        self.calls = 0               # 3. rate limit

    def predict(self, x, user):
        if not self.enabled:
            return {"action": "fallback", "reason": "model disabled"}
        if self.calls >= self.budget:
            return {"action": "fallback", "reason": "rate limit"}
        self.calls += 1
        p = self.model.predict_proba(x[None, :])[0]
        label, c = int(p.argmax()), float(p.max())
        # 4. abstain rather than guess
        action = "answer" if c >= self.threshold else "escalate"
        self.log.append({"user": user, "label": label, "conf": round(c, 3), "action": action})
        return {"action": action, "label": label, "confidence": round(c, 3)}

guard = GuardedModel(model)
easy = int(np.argmax(conf))                      # the most confident test image
hard = int(np.argmin(conf))                      # the least confident test image
print("\nGUARDED SERVING")
print("  confident input :", guard.predict(Xte[easy], "u-1"))
print("  ambiguous input :", guard.predict(Xte[hard], "u-1"))
print("  third call      :", guard.predict(Xte[easy], "u-1"))
print("  fourth call     :", guard.predict(Xte[easy], "u-1"))
guard.enabled = False
print("  after kill switch:", guard.predict(Xte[easy], "u-1"))
print("  audit log        :", guard.log)

# ---------- canary: compare a candidate against the live model before switching ----------
candidate = LogisticRegression(max_iter=5000, C=0.05).fit(Xtr, ytr)
share = 0.10
rng = np.random.default_rng(0)
on_canary = rng.random(len(yte)) < share
live_acc = (model.predict(Xte[~on_canary]) == yte[~on_canary]).mean()
can_acc = (candidate.predict(Xte[on_canary]) == yte[on_canary]).mean()
print(f"\nCANARY at {share:.0%}: live acc={live_acc:.4f} (n={int((~on_canary).sum())})  "
      f"candidate acc={can_acc:.4f} (n={int(on_canary.sum())})")
print("  promote?", "no - not enough evidence" if abs(live_acc - can_acc) < 0.02
      else "investigate the gap before promoting")
Output
SELECTIVE PREDICTION: answer only when confident, send the rest to a human
 threshold  coverage  accuracy on answered  sent to human
      0.00     1.000                0.9704              0
      0.50     0.969                0.9847             17
      0.80     0.830                0.9955             92
      0.90     0.711                1.0000            156
      0.99     0.165                1.0000            451

GUARDED SERVING
  confident input : {'action': 'answer', 'label': 7, 'confidence': 1.0}
  ambiguous input : {'action': 'escalate', 'label': 8, 'confidence': 0.307}
  third call      : {'action': 'answer', 'label': 7, 'confidence': 1.0}
  fourth call     : {'action': 'fallback', 'reason': 'rate limit'}
  after kill switch: {'action': 'fallback', 'reason': 'model disabled'}
  audit log        : [{'user': 'u-1', 'label': 7, 'conf': 1.0, 'action': 'answer'}, {'user': 'u-1', 'label': 8, 'conf': 0.307, 'action': 'escalate'}, {'user': 'u-1', 'label': 7, 'conf': 1.0, 'action': 'answer'}]

CANARY at 10%: live acc=0.9673 (n=490)  candidate acc=0.9800 (n=50)
  promote? no - not enough evidence

Reading the three blocks

Abstention buys accuracy at a known price. At threshold 0.00 the model answers everything at 0.9704. At 0.90 it answers 71% of cases at 1.0000 and hands 156 cases to a person. You have converted an error rate into a staffing number, and a staffing number can be budgeted.

Pushing further stops paying. At 0.99 coverage collapses to 16.5% for no accuracy gain over 0.90. There is a knee in this curve. Find yours and sit slightly past it.

The canary result is a lesson in restraint. The candidate scored 0.9800 against the live model's 0.9673. That looks like an improvement and is not evidence of one: n=50 gives a confidence interval of roughly plus or minus 4 percentage points. A 1.3-point difference on 50 samples is noise. Decide your required sample size before the canary starts, or you will promote noise.

Line by line

conf = proba.max(1) uses the top class probability as a confidence. It is the cheapest useful signal and it is not calibrated — a neural network reporting 0.9 is right well under 90% of the time. Fit temperature scaling on a validation split before you treat these numbers as probabilities.

self.enabled is a plain attribute here. In production it belongs in a feature flag or a config value that changes without a deploy. A kill switch that needs a release to pull is not a kill switch.

self.log.append({...}) records the confidence and the action, not the input. Log what you need to reconstruct a decision, and no more than your retention policy allows — see AI regulation.

action = "answer" if c >= self.threshold else "escalate" returns three outcomes from one function: answer, escalate, fallback. A serving path with only success and exception forces every uncertain case into a guess.

The monitoring you actually need

Ground truth arrives late or never, so accuracy is not your primary live signal. Watch these instead:

  • Escalation rate. A rise means the world moved, before you can prove it any other way.
  • Confidence distribution. Compare today's histogram against launch week. Shape changes precede accuracy changes.
  • Input drift. Distances between feature distributions now and at training time. Monitoring and drift covers the tests.
  • Per-slice volumes and outcomes. Aggregate stability hides a slice collapsing.
  • Human overrides and complaints. The most informative signal, and the one nobody instruments.

Common mistakes

Launching to 100% because staging looked good. Staging traffic is the traffic you imagined. Use 1%, then 5%, then 25%, with a hold at each step.

No fallback path. If the model is disabled and there is nothing behind it, the kill switch takes the product down. Keep the old rules or a simple heuristic wired in and tested.

Untested rollback. Practise it in a quiet week. The first attempt always finds something.

Alerting on model accuracy only. You often cannot compute it live. Alert on escalation rate, latency, error rate and input drift, which are available immediately.

Leaving humans no real authority. If the reviewer's override is ignored downstream, or they see 300 cases an hour, you have a rubber stamp. Measure override rate; if it is near zero, oversight is not happening.

Treating launch as the end. Schedule the first re-evaluation before you launch, and put it in the calendar with a name against it.

Try it yourself

Add a slice array marking 15% of the test set as a "new camera" group, and shift those features slightly. Recompute the escalation rate for that slice alone. Watch it climb well before the overall accuracy moves — that is your early-warning signal, and it is why per-slice monitoring is worth the effort.

What to learn next

Researcher — Mathematics and papers.

Selective prediction

Abstention is formalised as a predictor $f$ paired with a selection function $g: \mathcal{X} \to {0, 1}$:

$$ (f,g)(x) = \begin{cases} f(x) & g(x) = 1 \ \text{abstain} & g(x) = 0 \end{cases} $$

Two quantities characterise it:

$$ \text{coverage}(g) = \mathbb{E}[g(x)], \qquad \text{risk}(f,g) = \frac{\mathbb{E}\big[\ell(f(x), y)\,g(x)\big]}{\mathbb{E}[g(x)]} $$

The risk-coverage curve is the object to report; a single operating point hides the trade. El-Yaniv and Wiener (2010) establish the framework, and Geifman and El-Yaniv (2017) give SelectiveNet-style bounds for deep models with a guaranteed risk at a target coverage.

Softmax response is a strong baseline. Better selectors: deep ensembles (Lakshminarayanan et al., 2017), MC dropout, and learned selection heads. Ovadia et al. (2019) benchmark uncertainty methods under distribution shift and find that most degrade sharply — the setting where the selector matters most is the setting where it works least well.

Calibration

A model is calibrated if $P(Y = \hat{y} \mid \hat{p} = p) = p$. Guo et al. (2017) show modern networks are systematically over-confident, worsening with depth and width, and that single-parameter temperature scaling — dividing logits by $T$ fitted on a validation set by minimising NLL — removes most of the miscalibration without changing the argmax.

Expected calibration error, binned into $M$ bins:

$$ \text{ECE} = \sum_{m=1}^{M} \frac{|B_m|}{n}\,\big|\,\text{acc}(B_m) - \text{conf}(B_m)\,\big| $$

ECE is sensitive to binning scheme and bin count; report the scheme, or prefer an adaptive-binning or kernel estimator. Calibration on the training distribution does not transfer under shift, which is the finding most often ignored when a threshold is set once and never revisited.

Conformal prediction (Vovk et al.; Angelopoulos and Bates, 2021) offers distribution-free finite-sample coverage guarantees for prediction sets under exchangeability. Exchangeability fails under drift, and that is precisely when guarantees are wanted; split-conformal with periodic recalibration is the practical compromise.

Detecting failure without labels

Labels arrive late, so live monitoring is largely unsupervised.

  • Input drift. Per-feature two-sample tests (Kolmogorov-Smirnov, chi-squared) with multiple-testing correction; MMD for multivariate. Rabanser et al. (2019) evaluate shift-detection pipelines and find dimensionality reduction followed by univariate testing competitive with more elaborate methods.
  • Output drift. Shifts in the predicted-class distribution or the confidence histogram. Cheap, and sensitive in practice.
  • Proxy signals. Escalation rate, override rate, complaint volume, downstream conversion. Usually the earliest indicator.

Distinguish covariate shift ($P(X)$ changes, $P(Y \mid X)$ fixed) from concept drift ($P(Y \mid X)$ changes). Only the first is detectable from inputs alone; the second requires labels or a proxy for them, which is why concept drift is typically discovered through a business metric.

Rollout as sequential testing

A canary is a sequential experiment, and evaluating it repeatedly with a fixed-horizon test inflates the false-positive rate. Two correct approaches: fix the sample size in advance, or use always-valid inference — sequential probability ratio tests, or confidence sequences (Howard et al., 2021) — which permit continuous monitoring at controlled error rates.

Practical sizing: detecting a 1-point difference around 97% accuracy at conventional power needs on the order of thousands of samples per arm, not fifty. Compute the minimum detectable effect before the canary starts, and be prepared for the honest answer that your traffic cannot resolve the difference you care about.

Interference matters for interactive systems. Recommender canaries violate SUTVA through shared item pools and feedback loops; cluster or time-sliced randomisation is often necessary. See A/B testing a recommender.

Feedback loops

A deployed model changes the data it will next be trained on. Predictive policing models allocate patrols, patrols generate arrest records, records train the next model — Ensign et al. (2018) analyse this as a reinforcing process independent of any underlying crime distribution. Recommenders narrow the item distribution users can respond to, so logged feedback is confounded by exposure.

Mitigations: log the propensity of each exposure to permit inverse-propensity-weighted evaluation, retain a randomised holdout stream that never sees model output, and monitor the diversity of the action distribution over time. Sculley et al. (2015) catalogue these and other hidden costs; the paper remains the best short account of why ML systems accumulate maintenance debt faster than ordinary services.

Incident response

Borrow directly from site reliability practice, adapted for the fact that an ML failure is usually silent rather than loud.

  • Define an SLO on a user-visible quantity, not on model accuracy — escalation rate, dispute rate, time to resolution.
  • Error budgets convert "is it good enough" into a spend, and make the rollback decision a policy rather than an argument.
  • Blameless postmortems with a mandatory question: what signal would have caught this earlier, and why was it not instrumented?
  • Practise the rollback. An untested recovery path is a hypothesis.

Papers

What to learn next

What to learn next

These follow on from what you just read.

  • Legal and Compliance AI

    Legal documents as data

    Legal documents are numbered and hierarchical rather than free-flowing prose, and that numbering is the most reliable structure available for processing them.

  • Legal and Compliance AI

    Extracting clauses from contracts

    Clause extraction sorts contract clauses into categories like termination or payment, and a simple keyword approach reliably misses clauses that say the same thing in different words.

  • Legal and Compliance AI

    Legal RAG and fabricated citations

    Legal RAG grounds an AI's answer in retrieved case law, but the generated citations still need checking, because a fluent, correctly-formatted citation can point to a case that never existed.