Skip to content

Model with conditional interaction

In this example, we show global effects of a model with conditional interactions using PDP, ALE, and RHALE. In particular, we:

  1. show how to use effector to estimate the global effects using PDP, ALE, and RHALE
  2. provide the analytical formulas for the global effects
  3. test that (1) and (2) match

We will use the following model:

f(x1,x2,x3)=x121x2<0+x121x20+ex3

where the features x1,x2,x3 are independent and uniformly distributed in the interval [1,1].

The model has an interaction between x1 and x2 caused by the terms: f1,2(x1,x2)=x121x2<0+x121x20. This means that the effect of x1 on the output y depends on the value of x2 and vice versa. Therefore, there is no golden standard on how to split the effect of f1,2 to two parts, one that corresponds to x1 and one to x2. Each global effect method has a different strategy to handle this issue. Below we will see how PDP, ALE, and RHALE handle this interaction.

In contrast, x3 does not interact with any other feature, so its effect can be easily computed as ex3.

import numpy as np
import matplotlib.pyplot as plt
import effector

np.random.seed(21)

model = effector.models.ConditionalInteraction()
dataset = effector.datasets.IndependentUniform(dim=3, low=-1, high=1)
x = dataset.generate_data(1_000)

PDP

Effector

Let's see below the PDP effects for each feature, using effector.

pdp = effector.PDP(x, model.predict, dataset.axis_limits)
pdp.fit(features="all", centering=True)
for feature in [0, 1, 2]:
    pdp.plot(feature=feature, centering=True, y_limits=[-2, 2])

png

png

png

PDP states that:

  • x1 has a zero average effect on the model output
  • x2 has a constant effect when x2<0 or x2>0, but when moving from x2 to x2+ it adds (on average) +23 units to y
  • x3 has an effect of ex3

Derivations

How PDP leads to these explanations? Are they meaningfull? Let's have some analytical derivations. If you don't care about the derivations, skip the following three cells and go directly to the coclusions.

For x1:

PDP(x1)1Ni=1nf(x1,x/1i)1Ni=1Nx121x2i<0+x121x2i0+ex3ix121Ni=1n(1x2i<0+1x2i0)c

For x2:

PDP(x2)1Ni=1nf(x2,x/2i)1Ni=1n[(x1i)21x2<0+(x1i)21x20+ex3i][1NiNxi,12]1x2<0+[1NiNxi,12]1x20131x2<0+131x20+c

For x3:

PDP(x3)1Ni=1nf(x3,x/3i)ex3+c

Tests

def compute_centering_constant(func, start, stop, nof_points):
    x = np.linspace(start, stop, nof_points)
    y = func(x)
    return np.mean(y)


def pdp_ground_truth(feature, xs):
    if feature == 0:
        ff = lambda x: np.zeros_like(x)
        z = compute_centering_constant(ff, -1, 1, 1000)
        return ff(xs) - z
    elif feature == 1:
        ff = lambda x: -1 / 3 * (x < 0) + 1 / 3 * (x >= 0)
        z = compute_centering_constant(ff, -1, 1, 1000)
        return ff(xs) - z
    elif feature == 2:
        ff = lambda x: np.exp(x)
        z = compute_centering_constant(ff, -1, 1, 1000)
        return ff(xs) - z
xx = np.linspace(-1, 1, 100)
y_pdp = []
for feature in [0, 1, 2]:
    y_pdp.append(pdp_ground_truth(feature, xx))

plt.figure()
plt.title("PDP effects (ground truth)")
color_pallette = ["blue", "red", "green"]
for feature in [0, 1, 2]:
    plt.plot(
        xx, 
        y_pdp[feature], 
        color=color_pallette[feature], 
        linestyle="--",
        label=f"feature {feature + 1}"
    )
plt.legend()
plt.xlim([-1.1, 1.1])
plt.ylim([-2, 2])
plt.show()

png

# make a test
xx = np.linspace(-1, 1, 100)
for feature in [0, 1, 2]:
    y_pdp = pdp.eval(feature=feature, xs=xx, centering=True)
    y_gt = pdp_ground_truth(feature, xx)
    np.testing.assert_allclose(y_pdp, y_gt, atol=1e-1)

Conclusions

Are the PDP effects intuitive?

  • For x1 the effect is zero. The terms related to x1 are x121x2<0 and x121x20. Both terms involve an interaction with x2. Since x2U(1,1), almost half of the instances have x2i<0 and the the other half x2i0, so the the two terms cancel out.
  • For x2, the effect is constant when x2<0 or x2>0 but has a positive jump of 23 when moving from x2 to x2+. It makes sense; when x2<0 the active term is $-(x_1^i)^2 \mathbb{1}_{x_2 < 0} $ which adds a negative quantity to the output and when x20 the active term is (x1i)21x20 that adds something postive. Therefore in the transmission we observe a non-linearity.
  • For x3, the effect is ex3, as expected, since only the this term corresponds to x3 and has no interaction with other variables.

ALE

Effector

Let's see below the PDP effects for each feature, using effector.

ale = effector.ALE(x, model.predict, axis_limits=dataset.axis_limits)
ale.fit(features="all", centering=True, binning_method=effector.axis_partitioning.Fixed(nof_bins=31))

for feature in [0, 1, 2]:
    ale.plot(feature=feature, centering=True, y_limits=[-2, 2])

png

png

png

ALE states that: - x1 has a zero average effect on the model output (same as PDP) - x2 has a constant effect when x2<0 or x2>0, but when moving from x2 to x2+ it adds (on average) +23 units to y (same as PDP) - x3 has an effect of ex3 (same as PDP)

Derivations

ALE(x1)k=1kx11|Sk|i:xiSk[f(zk,x2i,x3i)f(zk1,x2i,x3i)]k=1kx11|Sk|i:x(i)Sk[zk21x2i<0+zk21x2i0+ex3i(zk121x2i<0+zk121x2i0+ex3i)]k=1kx11|Sk|i:x(i)Sk[zk2(1x2i<01x2i0)0+zk12(1x2i<01x2i0)0]k=1kx11|Sk|i:x(i)Sk00
ALE(x2)k=1kx21|Sk|i:x(i)Sk[f(xi1,zk,x3i)f(xi1,zk1,x3i]k=1kx21|Sk|i:x(i)Sk[(x1i)21zk<0+(x1i)21zk0((x1i)21zk1<0+(x1i)21zk10)]

For all bins, except the central, it holds that bin limits are either both negative or both positive, so the effects cancel out. For central bin, i.e., the one from 2K to 2K, the effect is (2x1i)2|Sk|=23K2=K3. Therefore, the ALE effect is:

ALE(x2){13 if x2<2K13 if x2>2Ka linear segment from 13 to 13 in between
ALE(x3)k=1kx31|Sk|i:x(i)Sk[f(x1i,x2i,zk)f(x1i,x2i,zk1)]k=1kx31|Sk|i:x(i)Sk[ezkezk1]ex3

Tests

def ale_ground_truth(feature, xs):
    if feature == 0:
        ff = lambda x: np.zeros_like(x)
        z = compute_centering_constant(ff, -1, 1, 1000)
        return ff(xs) - z
    elif feature == 1:
        K = 51
        ff = lambda x: -1/3 * (x < 0) + 1/3 * (x >= 0)
        z = compute_centering_constant(ff, -1, 1, 1000)
        return ff(xs) - z
    elif feature == 2:
        ff = lambda x: np.exp(x)
        z = compute_centering_constant(ff, -1, 1, 1000)
        return ff(xs) - z
xx = np.linspace(-1, 1, 100)
y_ale = []
for feature in [0, 1, 2]:
    y_ale.append(ale_ground_truth(feature, xx))

plt.figure()
plt.title("ALE effects (ground truth)")
color_pallette = ["blue", "red", "green"]
for feature in [0, 1, 2]:
    plt.plot(
        xx, 
        y_ale[feature], 
        color=color_pallette[feature], 
        linestyle="--",
        label=f"feature {feature + 1}"
    )
plt.legend()
plt.xlim([-1.1, 1.1])
plt.ylim([-2, 2])
plt.show()

png

xx = np.linspace(-1, 1, 100)
for feature in [1]:# [0, 1, 2]:
    y_ale = ale.eval(feature=feature, xs=xx, centering=True)
    y_gt = ale_ground_truth(feature, xx)

    # hack to remove the effect at undefined region
    if feature == 1:
        K = 31
        ind = np.logical_and(xx > -1/K, xx < 1/K)
        y_ale[ind] = 0
        y_gt[ind] = 0

    np.testing.assert_allclose(y_ale, y_gt, atol=1e-1)

Conclusions

Are the ALE effects intuitive?

ALE effects are identical to PDP effects which, as discussed above, can be considered intutive.

RHALE

Effector

Let's see below the RHALE effects for each feature, using effector.

rhale = effector.RHALE(x, model.predict, model.jacobian, axis_limits=dataset.axis_limits)
rhale.fit(features="all", centering=True)

for feature in [0, 1, 2]:
    rhale.plot(feature=feature, centering=True, y_limits=[-2, 2])

png

png

png

RHALE states that: - x1 has a zero average effect on the model output (same as PDP) - x2 has a zero average effect on the model output (different than PDP and ALE) - x3 has an effect of ex3 (same as PDP)

Derivations

RHALE(x1)k=1kx11|Sk|(zkzk1)i:xiSk[fx1(xi)]k=1kx11|Sk|(zkzk1)i:x(i)Sk[2x11x2i<0+2x11x2i0]k=1kx11|Sk|(zkzk1)i:x(i)Sk[2x1(1x2i<01x2i0)0]k=1kx11|Sk|i:x(i)Sk00
RHALE(x2)k=1kx21|Sk|(zkzk1)i:xiSk[fx2(xi)]k=1kx21|Sk|(zkzk1)i:xiSk{0 if x2<00 if x200
RHALE(x3)k=1kx31|Sk|(zkzk1)i:xiSk[fx3(xi)]k=1kx31|Sk|(zkzk1)i:xiSk[ex3]ex3
def rhale_ground_truth(feature, xs):
    if feature == 0:
        ff = lambda x: np.zeros_like(x)
        z = compute_centering_constant(ff, -1, 1, 1000)
        return ff(xs) - z
    elif feature == 1:
        K = 31
        ff = lambda x: np.zeros_like(x)
        z = compute_centering_constant(ff, -1, 1, 1000)
        return ff(xs) - z
    elif feature == 2:
        ff = lambda x: np.exp(x)
        z = compute_centering_constant(ff, -1, 1, 1000)
        return ff(xs) - z
xx = np.linspace(-1, 1, 100)
y_rhale = []
for feature in [0, 1, 2]:
    y_rhale.append(rhale_ground_truth(feature, xx))

plt.figure()
plt.title("RHALE effects (ground truth)")
color_pallette = ["blue", "red", "green"]
for feature in [0, 1, 2]:
    plt.plot(
        xx, 
        y_rhale[feature], 
        color=color_pallette[feature], 
        linestyle="-" if feature == 0 else "--",
        label=f"feature {feature + 1}"
    )
plt.legend()
plt.xlim([-1.1, 1.1])
plt.ylim([-2, 2])
plt.show()

png

for feature in [0, 1, 2]:
    y_ale = rhale.eval(feature=feature, xs=xx, centering=True)
    y_gt = rhale_ground_truth(feature, xx)
    np.testing.assert_allclose(y_ale, y_gt, atol=1e-1)

Conclusions

Are the RHALE effects intuitive?

RHALE does not add something new, compared to ALE and PDP, for features x1 and x3. For x2, however, it does not capture the abrupt increase by +23 units when moving from x2 to x2+, which can be considered as an error mode of RHALE. In fact, RHALE requires a differentiable black box model, and since f is not differentiable with respect to x2, that is why we get a slightly misleading result.