{"slug": "pokemon-individual-recourse", "title": "Pokemon Individual Recourse", "summary": "A new blog post from minimallysufficient.com applies the machine learning concept of individual recourse to Pokemon battles, showing how to determine which stats to increase to beat a specific boss. The post builds on a previous neural network model that predicts battle outcomes based on stats, and uses the open-source Pokemon Showdown engine to generate 90,000 simulated battles across 730 Pokemon. The author frames the approach within the recourse literature, citing Ustun 2019 and Wachter 2018, and demonstrates how optimization methods vary based on problem structure.", "body_md": "We’ve [previously trained](https://minimallysufficient.com/posts/neural-bradley-terry) a neural network to predict which Pokemon\nwill win a battle based on their stats. The motivation was tracking a\nPokemon’s evolution: vanilla [Bradley-Terry](https://minimallysufficient.com/posts/non-transitive-bradley-terry) models would invalidate\nthemselves but our model based on stats would still generalize well.\nThus as a Pokemon levels up and evolves we can see how its probability\nof defeating an opponent rises.\n\nThis raises the natural question: what if we wanted to interfere with evolution and direct that development? In particular we might ask: which stats should we increase to have a reasonable chance of beating a particular boss?\n\nFor the normal leveling-up process we can just read the stats for each\nlevel and iterate until we achieve the requisite probability. In the\ndirected case we need to be more clever as there’s many options we can\nchoose. There is a topic from the ML fairness literature that is\nhelpful here: *individual recourse*.\n\n## Individual Recourse[#](#individual-recourse)\n\nThe original paper (as far as I can tell) to introduce the idea of\nrecourse is [Ustun 2019](https://arxiv.org/abs/1809.06514) (Actionable Recourse in Linear Classification).\nTheir objective is:\n\n\\[ \\min_{r} \\; \\mathrm{cost}( r) \\quad \\text{subject to} \\quad \\hat{f}(x + r) = t \\]\n\nThat’s basically all you need to know. There’s a rather large\nliterature 1 but they all fundamentally are variations on\nthis theme. This is somewhat obscured as many of the subsequent papers\nsuch as\n\n[Wachter 2018](https://arxiv.org/abs/1711.00399)switch to a variant of the unconstrained/penalized version\n\n\\[ \\min_{r} \\; \\mathrm{cost}( r) + \\lambda \\, (\\hat{f}(x + r) - t)^{2} \\]\n\nThis reformulation is a practical choice as it’s convenient to optimize. And of course it has a solution even when the original is infeasible. But ultimately it’s doing the same thing as \\(\\lambda \\rightarrow \\infty\\) on satisfiable problems.\n\nThus we can summarize some of the literature as such:\n\n| Paper | Objective | Optimizer |\n|---|---|---|\n|\n\n[Wachter 2018](https://arxiv.org/abs/1711.00399)[REVISE (Joshi 2019)](https://arxiv.org/pdf/1907.09615)[Dandl 2020](https://arxiv.org/abs/2004.11165)[DICE (Mothilal 2020)](https://arxiv.org/abs/1905.07697)We see that many of these papers simply modify the formula, adding\nsome additional constraint targeting one of the *-ities*:\nsparsity 2, plausibility, diversity, or\ncausality.\n\nThis view helps make sense of the literature because the optimization algorithms are obscuring the nature of improvement. All of these choices between integer programming or gradient descent or spicier approaches like mixed optimization are more like implementation details. They fit the appropriate problem structure: like if you have neural networks gradients are cheap and easy while if everything is discrete then integer programming is the right way to go. We’ll indeed see this in our examples below.\n\n## Building out the data and model[#](#building-out-the-data-and-model)\n\nLast time we used a [Kaggle dataset](https://www.kaggle.com/datasets/terminus7/pokemon-challenge) of simulated battles. That’s enough\nfor predicting, but here we want to *act* on the model’s advice and\nthen check whether the advice was any good. So we need a battle engine\nwe can query such as the open-source [Pokemon Showdown](https://github.com/smogon/pokemon-showdown) engine.\n\nFor fights we’ll use the simulator’s built-in `RandomPlayerAI`\n\n. This\nis noisy as expected and could be improved but that’s a whole other\nblog post. We pit 730 pokemon against each other in 90000 battles\ncreating a similar dataset as before.\n\nOn top of this data we retrain exactly the model from the [previous\npost:](https://minimallysufficient.com/posts/neural-bradley-terry) a strength network plus an antisymmetric interaction term,\n\n\\[ P(a \\text{ beats } b) = \\sigma\\!\\big(\\mathrm{strength}(x_a) - \\mathrm{strength}(x_b) + \\mathrm{SASNN}(x_a, x_b)\\big), \\]\n\nwhere each \\(x\\) is the six stats and a one-hot encoding of the primary type.\n\nNow that we have our data and our model let’s start investigating recourse!\n\n## Recourse for Pokemon[#](#recourse-for-pokemon)\n\nWe’ll build up to four different versions of the cost:\n\n| Version | What \\(r\\) may touch | \\(\\mathrm{cost}( r)\\) | Optimizer |\n|---|---|---|---|\n| Unconstrained | any base stat, continuously | none (just need feasibility) | Gradient descent |\n| Cost-aware | any base stat, continuously | \\(\\lVert r\\rVert_2^2\\) | Gradient descent |\n| Diverse (DICE) | any base stat, continuously | + a diversity reward | Gradient descent |\n| Realistic | the trainer’s levers (EV/IV/nat/L) | money | DFS branch-and-bound |\n\nThe unconstrained, cost-aware, and diverse versions share the same underlying optimization just with escalating costs. They work on the base statistics themselves which is a little unrealistic. To fix that we instead optimize on the set of levers actually available to a trainer and cost them according to the money required to buy the items causing those levers. Note that as we change the form of the problem we need to switch the optimization strategy: gradient descent works for the first two but the discrete nature of the realistic cost leads us to a depth-first search approach.\n\nFor our recourse target, let’s pick Magikarp, arguably the weakest Pokemon, and pit it against Mewtwo: one of the stronger pokemon. The model gives it essentially no chance in the baseline configuration; let’s figure out what we need to do to get Magikarp to a 50-50.\n\n### Unconstrained Recourse[#](#unconstrained-recourse)\n\nWe’ll treat the six stats as free continuous variables and fix the type (can’t change that). Let’s start by figuring out how we can get a single feasible point before starting to worry about the costs.\n\nIt’s actually rather easy: just projected gradient ascent on the predicted probability. The projection is there to keep us from going either negative or above a max value.\n\n```\nfunction unconstrained_recourse(attacker, defender; t=0.5, lr=0.05, n_steps=6000, on_step=nothing)\n    xa = feat(attacker)\n    xd = feat(defender)\n    Δ = zeros(Float32, N_FEATURES)\n    fhat(d) = only(predict(full_model, xa .+ d, xd))\n\n    p = fhat(Δ)\n    on_step === nothing || on_step(0, Δ, p) # callback for tracing\n    p >= t && return Δ\n\n    for it in 1:n_steps # recourse may not be possible; hence finite steps\n        prev = copy(Δ)\n        g = Zygote.gradient(fhat, Δ)[1]\n        Δ[1:N_STATS] .+= lr .* g[1:N_STATS] # only update the things that are not fixed\n        Δ[1:N_STATS] .= clamp.(Δ[1:N_STATS], -xa[1:N_STATS], 1 .- xa[1:N_STATS])  # between 0 and 1 (normalized)\n        p = fhat(Δ)\n        on_step === nothing || on_step(it, Δ, p)\n        if p >= t # crossed the line: bisect back to it\n            lo = prev\n            hi = Δ\n            for _ in 1:40\n                mid = (lo .+ hi) ./ 2\n                fhat(mid) >= t ? (hi = mid) : (lo = mid)\n            end\n            return hi\n        end\n    end\n    return Δ\nend\n```\n\nWe get the following recourse:\n\n| Stat | Base | Change | Target |\n|---|---|---|---|\n| HP | 20 | +148 | 168 |\n| ATK | 10 | +57 | 67 |\n| DEF | 55 | +47 | 102 |\n| SPA | 15 | +25 | 40 |\n| SPD | 20 | +90 | 110 |\n| SPE | 80 | +28 | 108 |\n| P(win) | 0.001 | 0.5 |\n\nThus we find that we have to increase our statistics by a huge amount over 8x-ing HP and 5x-ing SPD to survive the fight. Attack is not that emphasized which is interesting: this is more of a turtle strategy.\n\nIt’s also informative to look at the path our optimization took:\n\nWe started a long way away from the target and it was basically flat for almost all of the time until finally cresting at the finish. We needed the gradient information from the model to do this: if we had to increase the stats manually and actually compete to find out if progress had been made this would have taken forever.\n\n### Cost Aware Recourse[#](#cost-aware-recourse)\n\nSo we know there’s at least a feasible solution; let’s now consider cost. We’ll use the usual L2 norm penalty:\n\n\\[ \\min_{r} \\; \\lVert r\\rVert^2 + \\lambda\\,(\\hat{f}(x+r) - t)^2 \\]\n\nwhen \\(\\lambda\\) is large we pin the solution on the boundary and then the \\(\\lVert r\\rVert^2\\) component keeps the norm small. This preferentially chooses recourse which moves all of the stats a little rather than one stat a lot (assuming both hit feasibility).\n\n```\nfunction min_norm_recourse(attacker, defender; t=0.5, λ=1000, lr=0.02, n_steps=8000, clip=0.02)\n    xa = feat(attacker)\n    xd = feat(defender)\n    fhat(d) = only(predict(full_model, xa .+ d, xd))\n    loss(d) = sum(abs2, d[1:N_STATS]) + λ * (fhat(d) - t)^2\n    Δ = zeros(Float32, N_FEATURES)\n    for _ in 1:n_steps\n        g = lr .* Zygote.gradient(loss, Δ)[1][1:N_STATS]\n        m = maximum(abs, g)\n        m > clip && (g .*= clip / m)\n        Δ[1:N_STATS] .-= g\n        Δ[1:N_STATS] .= clamp.(Δ[1:N_STATS], -xa[1:N_STATS], 1 .- xa[1:N_STATS])\n    end\n    # project exactly onto f̂ = t to be comparable with unconstrained\n    for _ in 1:50\n        err = fhat(Δ) - t\n        abs(err) < 1f-4 && break\n        gg = Zygote.gradient(fhat, Δ)[1][1:N_STATS]\n        Δ[1:N_STATS] .-= (err / (sum(abs2, gg) + 1f-8)) .* gg\n        Δ[1:N_STATS] .= clamp.(Δ[1:N_STATS], -xa[1:N_STATS], 1 .- xa[1:N_STATS])\n    end\n    return Δ\nend\n```\n\n| Stat | Base | Change | Target |\n|---|---|---|---|\n| HP | 20 | +134 | 154 |\n| ATK | 10 | +52 | 62 |\n| DEF | 55 | +58 | 113 |\n| SPA | 15 | +20 | 35 |\n| SPD | 20 | +98 | 118 |\n| SPE | 80 | +36 | 116 |\n| P(win) | 0.001 | 0.5 | |\n| ‖r‖₂ (vs ascent) | 0.924 | 0.917 |\n\nInterestingly there’s not a ton of difference from unconstrained and the cost is roughly the same (though a little smaller).\n\n### Diverse recourse[#](#diverse-recourse)\n\nOf course if you don’t want to go that particular route you’re a bit at a loss. It would be nice if we instead gave you a couple options: you could then opt for the build that matches your own preferred play style.\n\nOf course if we do this optimization a couple times we’ll get the same\nresult. We could add a stochastic component but even then we’re likely\nto wind up in the same basin 3.\nNo, to get diverse solutions we’ll need to optimize for it directly.\n\nWe’ll start with k potential recourses and then maximize a pairwise distance\n\n\\[ \\max_{r_1,\\dots,r_K}\\; \\sum_{k<l}\\lVert r_k - r_l\\rVert_2^2 \\quad\\text{s.t.}\\quad \\hat{f}(x + r_k) = t,\\ \\; r_k \\ge 0. \\]\n\nInstead of looking for a min-norm we’re now looking for K different recourses which all land on the boundary \\(\\hat{f}(x + r_{k}) = t\\) but which are as spread out as far as possible. We need to add a new constraint \\(r_{k} \\ge 0\\) as otherwise we’d start recommending decreasing stats to become even more diverse. Note we could have kept the min-norm penalty to get both diversity as well as avoiding huge changes but for didactic purposes we’ll drop it.\n\nWe solve this in two steps: the first with a diversity step which does gradient ascent on the pairwise distance penalizer. This knocks the solution off the boundary so we then project it back onto the boundary with a ray search. We seed our search at jittered copies of the unconstrained plan and iterate from there.\n\n```\nfunction dice_directions(attacker, defender; K=3, t=0.5, λd=0.05, n_steps=1200, seed=1)\n    Random.seed!(seed)\n    xa = feat(attacker)\n    xd = feat(defender)\n    hi = 1 .- xa[1:N_STATS]\n    padded(v) = vcat(v, zeros(Float32, n_types))\n\n    function project(u)\n        # we do a ray search since our model is approximately monotonic\n        uc = max.(u, 0)\n        sum(uc) <= 0 && return zeros(Float32, N_STATS)\n        f(a) = only(predict(full_model, xa .+ padded(min.(a .* uc, hi)), xd))\n        ahi = 1f0\n          while f(ahi) < t && ahi < 1f7\n              ahi *= 2\n          end   # bracket the boundary\n        f(ahi) < t && return min.(ahi .* uc, hi)                     # unreachable even maxed\n        alo = 0f0\n        for _ in 1:40\n            am = (alo + ahi)/2\n            f(am) >= t ? (ahi = am) : (alo = am)\n        end\n        min.(ahi .* uc, hi)\n    end\n\n    # start from K jittered copies of the unconstrained plan, each on the boundary\n    Δu = max.(unconstrained_recourse(attacker, defender, t=t)[1:N_STATS], 0f0)\n    cols = [project(Δu .+ 0.15 .* abs.(randn(Float32, N_STATS))) for _ in 1:K]\n    spread(M) = sum(sum(abs2, M[:, k] .- M[:, l]) for k in 1:K for l in 1:K if k < l)\n    for _ in 1:n_steps\n        M = reduce(hcat, cols)\n        g = Zygote.gradient(spread, M)[1]          # ascend the pairwise-distance reward\n        for k in 1:K\n            cols[k] = project(cols[k] .+ λd .* g[:, k])\n        end  # push apart, re-project onto the boundary\n    end\n    return reduce(hcat, cols)\nend\n```\n\nWe find three options from this:\n\n- A doesn’t touch HP at all and just invests heavily in ATK and SPA to get damage in while investing in SPD and to a lesser extent DEF for survivability.\n- B on the other hand ignores DEF and SPD altogether and jacks up HP to survive and uses SPE and some ATK to get licks in.\n- C joins B in beefing up HP but then turtles even harder investing in DEF and SPD.\n\nYou can obviously see the gameplay style implications: this is a nice touch to give a choose-your-own-adventure flavor to recourse.\n\n### Recourse with realistic costs[#](#recourse-with-realistic-costs)\n\nOf course, some of these are not actually achievable in-game. Aside\nfrom [cheating](https://youtu.be/Y-S6XwEIYIA?si=A92MhDeG0zVXlV3o), you only have basically four ways to increase stats: [EVs](https://bulbapedia.bulbagarden.net/wiki/Effort_values)\n(<=252/stat, 510 total), [IVs](https://bulbapedia.bulbagarden.net/wiki/Individual_values) (0-31), a [nature](https://bulbapedia.bulbagarden.net/wiki/Nature) (+/-10%), and level.\n\nWe can relate these to the base statistics (at level 50) with the following [code](https://bulbapedia.bulbagarden.net/wiki/Individual_values#Determination_of_stats):\n\n```\n# stat order: 1 hp, 2 atk, 3 def, 4 spa, 5 spd, 6 spe\nis_hp(statidx) = (statidx == 1)\nfunction actual_stat(base, iv, ev, level, nature, statidx)\n    core = fld((2base + iv + fld(ev, 4)) * level, 100)\n    return is_hp(statidx) ? core + level + 10 : floor(Int, (core + 5) * nature)\nend\n\nconst NATURE_NAME = Dict(\n    (0,0)=>\"Serious\",\n    (2,3)=>\"Lonely\",(2,4)=>\"Adamant\",(2,5)=>\"Naughty\",(2,6)=>\"Brave\",\n    (3,2)=>\"Bold\",(3,4)=>\"Impish\",(3,5)=>\"Lax\",(3,6)=>\"Relaxed\",\n    (4,2)=>\"Modest\",(4,3)=>\"Mild\",(4,5)=>\"Rash\",(4,6)=>\"Quiet\",\n    (5,2)=>\"Calm\",(5,3)=>\"Gentle\",(5,4)=>\"Careful\",(5,6)=>\"Sassy\",\n    (6,2)=>\"Timid\",(6,3)=>\"Hasty\",(6,4)=>\"Jolly\",(6,5)=>\"Naive\")\nnature_choices() = collect(keys(NATURE_NAME))\n\nfunction nature_vec(pj, mj)\n    ν = ones(Float32, N_STATS)\n    pj > 0 && (ν[pj] = 1.1; ν[mj] = 0.9)\n    return ν\nend\n\nfunction levers_to_x(name, IVs, EVs, ν, L)\n    a = [actual_stat(base_of(name)[j], IVs[j], EVs[j], L, ν[j], j) for j in 1:N_STATS]\n    return to_input(a, type1_of(name))\nend\n```\n\nThe costs vary as well; interestingly, we can actually price them in terms of the in-game currency. In code we have\n\n``` js\nconst EV_YEN    = 1_000 # a Vitamin gives +10 EV\nconst MINT_YEN  = 20_000 # one Mint buys any non-neutral nature\nconst CAP_YEN   = 20_000 # a Bottle Cap Hyper-Trains one IV to 31\nconst CANDY_YEN = 3_000 # nominal ₽ per XL Exp. Candy (30k XP)\n\n# Leveling isn't linear: total XP follows the Slow growth group for Magikarp\n# xp(L) = 1.25·L³, so each successive level costs progressively more candies.\ncandies_to(L) = ceil(Int, max(0, 1.25 * L^3 - 1.25 * Lstar^3) / 30_000)\nlevel_cost(L) = candies_to(L) * CANDY_YEN\n\nfunction lever_cost(EVs, nature, IVs, L)\n    ev  = sum(EVs) * EV_YEN\n    nat = nature == (0, 0) ? 0 : MINT_YEN\n    iv  = sum(IVs .== 31) * CAP_YEN\n    lvl = level_cost(L)\n    (ev=ev, nature=nat, iv=iv, level=lvl, total=ev + nat + iv + lvl)\nend\n```\n\nTo optimize this we need to consider discrete choices. Thus we need to change up our optimization from gradient descent.\n\nWe’ll use depth-first search but we’ll need to do a little pruning. Level is our biggest factor and for some levels it’s simply not possible to achieve our target win probability. We can check this by maxing out every stat as an optimistic upper bound: if that doesn’t achieve the target then we assume nothing can. Now that’s not exactly true since we can have non-monotonicity in our model but it’s a reasonable heuristic.\n\n```\nfunction stat_gains(attacker, defender, IVs, ν, L; ev_step=10)\n    xd = feat(defender)\n    map(1:N_STATS) do j\n        e = zeros(Int, N_STATS); e[j] = ev_step\n        only(predict(full_model, levers_to_x(attacker, IVs, e, ν, L), xd))\n    end\nend\n\nfunction greedy_cost(attacker, defender, IVs, ν, L; t=0.5f0, ev_total=510, ev_cap=250, ev_step=10)\n    xd = feat(defender)\n    EVs = zeros(Int, N_STATS)\n    pcur = only(predict(full_model, levers_to_x(attacker, IVs, EVs, ν, L), xd))\n    while sum(EVs) + ev_step <= ev_total && pcur < t\n        bj = 0\n        bp = pcur\n        for j in 1:N_STATS\n            EVs[j] + ev_step <= ev_cap || continue\n            EVs[j] += ev_step\n            p = only(predict(full_model, levers_to_x(attacker, IVs, EVs, ν, L), xd))\n            EVs[j] -= ev_step\n            p > bp && (bp, bj = p, j)\n        end\n        bj == 0 && break\n        EVs[bj] += ev_step\n        pcur = bp\n    end\n    pcur >= t ? EVs : nothing\nend\n\nfunction realistic_recourse(attacker, defender; t=0.5f0, Lmax=100, ev_cap=250, ev_step=10)\n    IVs = fill(31, N_STATS)\n    xd = feat(defender)\n    ivcost = sum(IVs .== 31) * CAP_YEN\n    best = Ref(Inf)\n    bestsol = Ref{Any}(nothing)\n    pwin(ν, L, EVs) = only(predict(full_model, levers_to_x(attacker, IVs, EVs, ν, L), xd))\n\n    for L in Lmax:-5:Lstar, (pj, mj) in nature_choices()\n        fixed = level_cost(L) + ivcost + (pj == 0 ? 0 : MINT_YEN)\n        fixed >= best[] && continue                              # cheapest possible build too pricey\n        ν = nature_vec(pj, mj)\n        pwin(ν, L, fill(ev_cap, N_STATS)) >= t || continue       # optimistic ceiling: unreachable even maxed\n\n        function record!(EVs)\n            best[] = fixed + sum(EVs) * EV_YEN\n            bestsol[] = (p=pwin(ν, L, EVs), EVs=copy(EVs), IVs=IVs, nature=(pj,mj),\n                         L=L, cost=sum(EVs), yen=best[])\n        end\n\n        # greedy feasible build → tighten the ₽ incumbent cheaply before the exact search\n        gEV = greedy_cost(attacker, defender, IVs, ν, L, t=t, ev_cap=ev_cap, ev_step=ev_step)\n        gEV !== nothing && fixed + sum(gEV) * EV_YEN < best[] && record!(gEV)\n\n        # exact DFS: pile whole vitamins onto the highest-gain stats first, pruning any\n        # partial build whose running ₽ already meets the incumbent\n        cand = sortperm(stat_gains(attacker, defender, IVs, ν, L, ev_step=ev_step), rev=true)\n        EVs  = zeros(Int, N_STATS)\n        function dfs(ci)\n            fixed + sum(EVs) * EV_YEN >= best[] && return\n            pwin(ν, L, EVs) >= t && return record!(EVs)\n            for i in ci:N_STATS\n                j = cand[i]\n                EVs[j] + ev_step <= ev_cap || continue\n                EVs[j] += ev_step\n                dfs(i)\n                EVs[j] -= ev_step\n            end\n        end\n        dfs(1)\n    end\n    bestsol[]   # nothing if unreachable\nend\n```\n\n| Lever | Setting |\n|---|---|\n| Level | 100 |\n| Nature | Sassy |\n| IVs | all 31 |\n| ATK EVs | 10 |\n| SPD EVs | 120 |\n| Total EVs | 130 |\n| Cost (₽) | 381000 |\n\n## Does the advice actually work?[#](#does-the-advice-actually-work)\n\nThe whole point of this exercise was to get Magikarp to an even match with Mewtwo. We’ve got a bunch of different recommended builds which all achieve this in the model: let’s see if this holds true in the engine.\n\n| Plan | Model P(win) | Real win rate |\n|---|---|---|\n| baseline | 0.001 | 0.0 |\n| unconstrained | 0.5 | 0.04 |\n| min-norm | 0.5 | 0.01 |\n| dice 1 | 0.5 | 0.16 |\n| dice 2 | 0.5 | 0.005 |\n| dice 3 | 0.5 | 0.0 |\n| realistic | 0.502 | 0.53 |\n\nAnd they very much do not! Only the realistic succeeds; the others have pitiful chances.\n\nIt’s informative to understand why: when you look at the distribution of observed stats you find that the other recourse options have stats which are well into the tail of the distributions. The model is extrapolating off its training distribution and thus is not going to perform well.\n\nOf course there could be another reason why the plans fail. We’ve forgot about causality! Stats are not the whole story and the model can only see stats. Our boosted Magikarp still has very weak moves: Splash and Tackle. We have a clear unobserved confounder: it’s rather remarkable we achieved the 50-50 win rate! If we had removed Tackle it’d be even worse!\n\nMore generally all of this recourse speaks only to the model: we can\ndeterministically flip the verdict not the underlying probability of\nthe event. You could see this clearly if we had some variable like\n`battle_win_rate`\n\nwhich obviously is correlated with winning more. We\ncan juice it by beating up on a weak Pokemon. Doing so (and nothing\nelse) would of course do nothing for our probability of winning. This\nis exactly the [causal recourse](https://arxiv.org/pdf/2210.15709) problem, and you see it in the wild all\nthe time: [Volkswagen](https://en.wikipedia.org/wiki/Volkswagen_emissions_scandal) realizing it only needed to reduce its *testing*\nemissions, or teachers realizing they just need to teach to the test.\n\nThis leads to an interesting conundrum: does providing recourse to\nindividuals break the ability to use the model? Covariates which were\nmerely correlational are still useful for prediction. But only if\nthey’re not gamed (see [König et al 2025](https://arxiv.org/pdf/2506.15366)). Recourse though basically\ntells us which variables to game thus we have to only use causal\nvariables. But, of course, if we had a reliable causal model we’d\nalready be using it. It kind of makes you want to adopt the Oracle of\nDelphi method and have your recourse be very cryptic: instead of\ntelling folks “increase the average age of your credit cards” we\nshould tell them “wisdom comes with experience beware the new”. Then\nmaybe they’ll improve their credit without learning exactly what the\nmodel is looking for?\n\n-\nYou’ll notice that this is exactly the same formulation as the original adversarial examples\n\n[paper](https://arxiv.org/abs/1312.6199)! They just use \\(||r||_{2}\\) as the cost. Only the intent is different: for adversarial examples instead of making recourse cheaper you’re actually trying to make recourse very costly![↩︎](#fnref:1) -\nIt’s not clear to me that you want sparsity. The Ustun paper makes the remark that if you have one unconstrained feature you always have recourse by just jacking that value to infinity. Which seems like you are going to overfit.\n\n[↩︎](#fnref:2) -\nThis raises the natural question: is this a convex problem? If yes then you’d always end up in the same place. For certain models I think this could hold but in general no. The set of points which satisfy our target is not necessarily convex: you can have many different local optima around which disjoint neighborhoods all satisfy the constraint. Even if you have just a single global optimum your superlevel sets need not be convex either\n\n[↩︎](#fnref:3)", "url": "https://wpnews.pro/news/pokemon-individual-recourse", "canonical_source": "https://minimallysufficient.com/posts/pokemon-individual-recourse/", "published_at": "2026-08-15 07:00:00+00:00", "updated_at": "2026-08-21 02:12:19.482321+00:00", "lang": "en", "topics": ["machine-learning", "artificial-intelligence"], "entities": ["minimallysufficient.com", "Pokemon", "Pokemon Showdown", "Kaggle", "Ustun 2019", "Wachter 2018"], "alternates": {"html": "https://wpnews.pro/news/pokemon-individual-recourse", "markdown": "https://wpnews.pro/news/pokemon-individual-recourse.md", "text": "https://wpnews.pro/news/pokemon-individual-recourse.txt", "jsonld": "https://wpnews.pro/news/pokemon-individual-recourse.jsonld"}}