Site icon R-bloggers

Ball Progression is All You Need

[This article was first published on Tony's Blog, and kindly contributed to R-bloggers]. (You can report issue about the content on this page here)
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.
< section id="introduction" class="level1">

Introduction

I’ve written a lot about expected goals (xG) in soccer, but I haven’t yet talked much about possession value (PV) models1, another big topic in soccer analytics. What are they? Well, every PV model is different, but they all generally try to assign value to every on-ball action on the pitch. Such a model can help inform decisions about how to improve player and team performance.

I heard someone recently say something like “PV models in soccer basically come down to ball progression”. That’s an interesting thought, and I add a hunch that it probably isn’t too wrong.

One way of getting at that idea is to look at how your PV model treats incomplete passes. Does it say that all long passes are “good”? What is the importance of the starting and end points of the pass? How does PV for an unsuccessful pass compare to a successful one, holding all else equal?

I attempt to answer some of these questions with a VAEP model–an open-source PV model.23

< section id="possession-value-pv-for-passes" class="level1">

Possession Value (PV) for Passes

< section id="completed-passes" class="level2">

Completed Passes

We’ll want to eventually look at the PV of incomplete passes, but it’s probably easier to start with completed passes, as we have a pretty strong intuition about them–pass the ball successfully closer to the goal, and you’re most likely helping your team (i.e. positive PV).

< section id="from-one-spot-to-anywhere-on-the-pitch" class="level3">

From One Spot, To Anywhere on the Pitch

In the interactive 8×12 pitch below, the blue cell illustrates where a pass is made, and the colored cells illustrate the average PV associated with all historicalLy successful passes made to that area. Hovering over a cell shows the PV value above the pitch as well.4

Overall, I’d say that this illustration matches intuition–forward completed passes into the final third should be assigned a non-trivial positive value.

< details class="hidden"> < summary>Code
{
  const chart = d3.create("div").style("background-color", "8f8f8f")
  const title = chart.append("div").attr("id", "heatmap-title-complete-empirical")
  title.append("p").html(`PV: <span id='pv-value-complete-empirical'>0</span>`)
  chart.append("div").attr("id", "heatmap-complete-empirical")

  const legendSwatchContainer = chart.append("div")
    .attr("id", "heatmap-legend-complete-empirical")
    .style("display", "flex")
    .style("flex-direction", "column")
    .style("align-items", "center")
    .style("width", "100%");

  const legendRange = [
    1.1 * d3.min(colorScaleCompleteRange),
    1.1 * d3.max(colorScaleCompleteRange)
  ];
  const stepSize = (legendRange[1] - legendRange[0]) / (swatchParams.num - 1);
  const legendSwatches = d3.range(legendRange[0], legendRange[1] + stepSize, stepSize);
  legendSwatches[legendSwatches.length - 1] = legendRange[1];

  const totalLegendWidth = swatchParams.width * swatchParams.num;

  const swatchRow = legendSwatchContainer.append("div")
    .style("display", "flex")
    .style("justify-content", "center")
    .style("width", "100%");

  swatchRow.selectAll("div")
    .data(legendSwatches)
    .enter()
    .append("div")
    .style("width", `${swatchParams.width}px`)
    .style("height", `${swatchParams.height}px`)
    .style("background-color", d => colorScaleComplete(d));

  const labelRow = legendSwatchContainer.append("div")
    .style("display", "flex")
    .style("justify-content", "center")
    .style("width", `${totalLegendWidth}px`);

  labelRow.selectAll("span")
    .data(colorScaleCompleteRange)
    .enter()
    .append("span")
    .text(d => {
      if (d === d3.min(colorScaleCompleteRange)) {
        return d + " <=";
      } else if (d === d3.max(colorScaleCompleteRange)) {
        return ">= " + d;
      }
      return d;
    })
    .style("flex", d => d === 0 ? "1" : null)
    .style("text-align", "center")

  return chart.node();
}
Figure 1: A heatmap showing the average possession value (PV) of historically completed passes from the center spot (annotated in blue) to all areas on the pitch. The relative frequency of successful passes from the center spot to each other cell is shown as a percentage. The exact PV value associated with a complete pass ending at the hover point can be viewed above the pitch. Black cells represent areas to which successful passes from the center spot have never been made.
< details class="hidden"> < summary>Code
{
  const heatmap_complete_empirical = d3_soccer.heatmap(pitch)
    .colorScale(colorScaleComplete)
    .enableInteraction(true)
    .onSelect((x, y, v) => {
      const cappedValue = Math.min(Math.max(v, -1), 1);
      d3.select("#pv-value-complete-empirical").text(cappedValue.toFixed(3));
    })
    .parent_el("#heatmap-complete-empirical")
    .interpolate(false);

  d3.select("#heatmap-complete-empirical")
    .html("")
    .datum(complete_empirical_pv_data)
    .call(heatmap_complete_empirical);

  const svg = d3.select("#heatmap-complete-empirical").select("svg");

  const cells = svg.selectAll(".cell");

  cells.each(function(d, i) {
    const cell = d3.select(this);
    const bbox = this.getBBox();

    d3.select(this.parentNode)
      .append("text")
      .attr("x", bbox.x + bbox.width / 2)
      .attr("y", bbox.y + bbox.height / 2)
      .attr("text-anchor", "middle")
      .attr("alignment-baseline", "central")
      .style("-size", "3px")
      .style("pointer-events", "none")
      .text((d.prop * 100).toFixed(1) + "%");
  });

  svg.append("rect")
    .attr("x", passStartParams.x)
    .attr("y", passStartParams.y)
    .attr("width", cellParams.width)
    .attr("height", cellParams.height)
    .style("stroke", "blue")
    .style("fill", "none")
    .style("stroke-width", "1px");
}

Note that the gradient in the pitch above is for PV, not for the relative frequency of completed passes from the center spot, which is instead shown as overlayed text. While passes into the box from the center spot have really strong positive PV, they’re uncommon because defenders are generally looking to stop those kinds of threatening passes.

The gradient in the plot below illustrates the relative frequency of successful passes from the center spot directly.

< details class="hidden"> < summary>Code
{
  const chart = d3.create("div").style("background-color", "8f8f8f")
  const title = chart.append("div").attr("id", "heatmap-title-complete-empirical-prop")
  chart.append("div").attr("id", "heatmap-complete-empirical-prop")

  return chart.node();
}
Figure 2: A heatmap where the gradient and text illustrate the relative frequency of historically successful passes from the center spot (annotated in blue) to all areas on the pitch. Black cells represent areas to which successful passes from the center spot have never been made.
< details class="hidden"> < summary>Code
{
  const colorScaleSeq = d3.scaleSequential(d3.interpolateRgb("white", "gold"))
    .domain([0, 0.1])
    .clamp(true)
  const heatmap_complete_empirical_prop = d3_soccer.heatmap(pitch)
    .colorScale(colorScaleSeq)
    .enableInteraction(false)
    .parent_el("#heatmap-complete-empirical-prop")
    .interpolate(false);

  d3.select("#heatmap-complete-empirical-prop")
    .html("")
    .datum(complete_empirical_prop_data)
    .call(heatmap_complete_empirical_prop);

  const svg = d3.select("#heatmap-complete-empirical-prop").select("svg");

  const cells = svg.selectAll(".cell");

  cells.each(function(d, i) {
    const cell = d3.select(this);
    const bbox = this.getBBox();

    d3.select(this.parentNode)
      .append("text")
      .attr("x", bbox.x + bbox.width / 2)
      .attr("y", bbox.y + bbox.height / 2)
      .attr("text-anchor", "middle")
      .attr("alignment-baseline", "central")
      .style("-size", "3px")
      .style("pointer-events", "none")
      .text((d.prop * 100).toFixed(1) + "%");
  });

  svg.append("rect")
    .attr("x", passStartParams.x)
    .attr("y", passStartParams.y)
    .attr("width", cellParams.width)
    .attr("height", cellParams.height)
    .style("stroke", "blue")
    .style("fill", "none")
    .style("stroke-width", "1px");
}
< section id="from-anywhere-on-the-pitch-to-anywhere-on-the-pitch" class="level3">

From Anywhere on the Pitch, To Anywhere on the Pitch

Now, to give the full picture, the interactive pitch below dynamically updates to show the average PV values associated with a pass starting from any cell that you hover over. The minimum and maximum PV achieved with a successful pass from the hovered spot are shown in the text above the pitch.

< details class="hidden"> < summary>Code
{
  const chart = d3.create("div").style("background-color", "8f8f8f")
  const title = chart.append("div").attr("id", "heatmap-title-complete-empirical-nested")
  title.append("p").html(`min PV: <span id='pv-min-complete-empirical-nested'>0</span>, max PV: <span id='pv-max-complete-empirical-nested'>0</span>`)
  chart.append("div").attr("id", "heatmap-complete-empirical-nested")
  
  const legendSwatchContainer = chart.append("div")
    .attr("id", "heatmap-legend-complete-empirical-nested")
    .style("display", "flex")
    .style("flex-direction", "column")
    .style("align-items", "center")
    .style("width", "100%");
  
  const legendRange = [
    1.1 * d3.min(colorScaleCompleteRange),
    1.1 * d3.max(colorScaleCompleteRange)
  ];
  const stepSize = (legendRange[1] - legendRange[0]) / (swatchParams.num - 1);
  const legendSwatches = d3.range(legendRange[0], legendRange[1] + stepSize, stepSize);
  legendSwatches[legendSwatches.length - 1] = legendRange[1];
  
  const totalLegendWidth = swatchParams.width * swatchParams.num;
  
  const swatchRow = legendSwatchContainer.append("div")
    .style("display", "flex")
    .style("justify-content", "center")
    .style("width", "100%");
  
  swatchRow.selectAll("div")
    .data(legendSwatches)
    .enter()
    .append("div")
    .style("width", `${swatchParams.width}px`)
    .style("height", `${swatchParams.height}px`)
    .style("background-color", d => colorScaleComplete(d));
  
  const labelRow = legendSwatchContainer.append("div")
    .style("display", "flex")
    .style("justify-content", "center")
    .style("width", `${totalLegendWidth}px`);
  
  
  labelRow.selectAll("span")
    .data(colorScaleCompleteRange)
    .enter()
    .append("span")
    .text(d => {
      if (d === d3.min(colorScaleCompleteRange)) {
        return d + " <=";
      } else if (d === d3.max(colorScaleCompleteRange)) {
        return ">= " + d;
      }
      return d;
    })
    .style("flex", d => d === 0 ? "1" : null)
    .style("text-align", "center")
  
  return chart.node();

}
Figure 3: A heatmap showing the average possession value (PV) of historically completed pass from the hover spot to all areas on the pitch. The relative frequency of successful passes from the hover spot to each other cell is shown as a percentage. The highest and lowest PV values across all end points associated with a completed pass from the hover point are shown above the pitch. Black cells represent areas to which successful passes from the hover spot have never been made.
< details class="hidden"> < summary>Code
{  
  const heatmap_complete_empirical_nested = d3_soccer.heatmap(pitch)
    .colorScale(d3.scaleLinear().domain([-1, 1]).range(["white", "white"]))
    .enableInteraction(true)
    .onSelect((x, y, v) => {
      const rawMinValue = d3.min(v, d => d.value);
      const rawMaxValue = d3.max(v, d => d.value);
      const minValue = Math.max(rawMinValue, -1);
      const maxValue = Math.min(rawMaxValue, 1);
  
      d3.select("#pv-min-complete-empirical-nested").text(minValue.toFixed(3));
      d3.select("#pv-max-complete-empirical-nested").text(maxValue.toFixed(3));
      const cells = d3
        .select("#heatmap-complete-empirical-nested")
        .selectAll("rect.cell")
        .data(v)
  
      cells.enter()
        .merge(cells)
        .attr("x", d => d.x)
        .attr("y", d => d.y)
        .attr("width", d => d.width)
        .attr("height", d => d.height)
        .style("fill", d => colorScaleComplete(+d.value));

      d3.select("#heatmap-complete-empirical-nested")
        .selectAll("text")
        .remove();
        
      cells.each(function(d, i) {
        const cell = d3.select(this.parentNode);
        const bbox = this.getBBox();
        cell.append("text")
          .attr("x", bbox.x + bbox.width / 2)
          .attr("y", bbox.y + bbox.height / 2)
          .attr("text-anchor", "middle")
          .attr("alignment-baseline", "central")
          .style("-size", "3px")
          .style("pointer-events", "none")
          .text((d.prop * 100).toFixed(1) + "%");
      });
      
      cells.exit().remove();

      d3.select("#heatmap-complete-empirical-nested")
        .selectAll("rect.cell")
        .data(complete_empirical_nested_pv_data)

    })
    .parent_el("#heatmap-complete-empirical-nested")
    .interpolate(false);
  
  d3.select("#heatmap-complete-empirical-nested")
    .html("")
    .datum(complete_empirical_nested_pv_data)
    .call(heatmap_complete_empirical_nested);
}

There are several takeaways one might have from this view, but the big one that I have is this: As you move your mouse (i.e. the starting point of the pass) from the defender’s box to the opponent’s box, the consolidated green box of +0.025 PV doesn’t change much. It stays basically at around the final quarter of the pitch. So you can’t just complete a 30-yard pass from the top of your own box progressing the ball towards the middle of the pitch and expect to get anywhere near the same PV as completing a 30-yard pass from the center of the pitch to near the opponent’s 18-yard box. The end point really matters.

This conclusion gets at our primary question–“Are all long passes good?”–to which the answer so far is “not quite” (in the sense that “good” is more than just “positive PV” for completed passes). A long completed pass in your own half doesn’t boast a huge positive PV, unless it ends up near the opponent’s box.

To get a more complete perspective, we’ll plot out the PV for incomplete passes to see what the answer is there.

< section id="incomplete-passes" class="level2">

Incomplete Passes

< section id="from-one-spot-to-anywhere-on-the-pitch-1" class="level3">

From One Spot, To Anywhere on the Pitch

Let’s start with an example again, looking at PV for unsuccessful passes from the center spot.

< details class="hidden"> < summary>Code
{
  const chart = d3.create("div").style("background-color", "8f8f8f")
  const title = chart.append("div").attr("id", "heatmap-title-incomplete-empirical")
  title.append("p").html(`PV: <span id='pv-value-incomplete-empirical'>0</span>`)
  chart.append("div").attr("id", "heatmap-incomplete-empirical")

  const legendSwatchContainer = chart.append("div")
    .attr("id", "heatmap-legend-incomplete-empirical")
    .style("display", "flex")
    .style("flex-direction", "column")
    .style("align-items", "center")
    .style("width", "100%");

  const legendRange = [
    1.1 * d3.min(colorScaleIncompleteRange),
    1.1 * d3.max(colorScaleIncompleteRange)
  ];
  const stepSize = (legendRange[1] - legendRange[0]) / (swatchParams.num - 1);
  const legendSwatches = d3.range(legendRange[0], legendRange[1] + stepSize, stepSize);
  legendSwatches[legendSwatches.length - 1] = legendRange[1];

  const totalLegendWidth = swatchParams.width * swatchParams.num;

  const swatchRow = legendSwatchContainer.append("div")
    .style("display", "flex")
    .style("justify-content", "center")
    .style("width", "100%");

  swatchRow.selectAll("div")
    .data(legendSwatches)
    .enter()
    .append("div")
    .style("width", `${swatchParams.width}px`)
    .style("height", `${swatchParams.height}px`)
    .style("background-color", d => colorScaleIncomplete(d));

  const labelRow = legendSwatchContainer.append("div")
    .style("display", "flex")
    .style("justify-content", "center")
    .style("width", `${totalLegendWidth}px`);

  labelRow.selectAll("span")
    .data(colorScaleIncompleteRange)
    .enter()
    .append("span")
    .text(d => {
      if (d === d3.min(colorScaleIncompleteRange)) {
        return d + " <=";
      } else if (d === d3.max(colorScaleIncompleteRange)) {
        return ">= " + d;
      }
      return d;
    })
    .style("flex", d => d === 0 ? "1" : null)
    .style("text-align", "center")

  return chart.node();
}
Figure 4: A heatmap showing the average possession value (PV) of historically incomplete passes from the center spot (annotated in blue) to all areas of the pitch. The relative frequency of unsuccessful passes from the center spot to each other cell is shown as a percentage. The exact PV value associated with an incomplete pass ending at the hover point can be viewed above the pitch. Black cells represent areas to which unsuccessful passes from the center spot have never been made.
< details class="hidden"> < summary>Code
{
  const heatmap_incomplete_empirical = d3_soccer.heatmap(pitch)
    .colorScale(colorScaleIncomplete)
    .enableInteraction(true)
    .onSelect((x, y, v) => {
      const cappedValue = Math.min(Math.max(v, -1), 1);
      d3.select("#pv-value-incomplete-empirical").text(cappedValue.toFixed(3));
    })
    .parent_el("#heatmap-incomplete-empirical")
    .interpolate(false);

  d3.select("#heatmap-incomplete-empirical")
    .html("")
    .datum(incomplete_empirical_pv_data)
    .call(heatmap_incomplete_empirical);

  const svg = d3.select("#heatmap-incomplete-empirical").select("svg");

  const cells = svg.selectAll(".cell");

  cells.each(function(d, i) {
    const cell = d3.select(this);
    const bbox = this.getBBox();

    d3.select(this.parentNode)
      .append("text")
      .attr("x", bbox.x + bbox.width / 2)
      .attr("y", bbox.y + bbox.height / 2)
      .attr("text-anchor", "middle")
      .attr("alignment-baseline", "central")
      .style("-size", "3px")
      .style("pointer-events", "none")
      .text((d.prop * 100).toFixed(1) + "%");
  });

  svg.append("rect")
    .attr("x", passStartParams.x)
    .attr("y", passStartParams.y)
    .attr("width", cellParams.width)
    .attr("height", cellParams.height)
    .style("stroke", "blue")
    .style("fill", "none")
    .style("stroke-width", "1px");
}

I think this grid is fairly intuitive.5 Incomplete passes backward have fairly negative PVs, as those are turnovers probably setting up the opponent for good scoring opportunities. Incomplete passes forward mostly have neutral PVs, with some spots on the pitch having slightly positive PVs. Notably, a positive PV for an incomplete pass is a non-trivial revelation.

Some of the positive PV cells include the area at the top of the 18-yard box, i.e. “zone 14”. You can make the argument that the “risk” of losing possession to passes to zone 14 is justified from the potential to take a shot. Further, a loss of possession in this area can be advantageous, as it leaves the opponent likely in a vulnerable position.

< section id="from-anywhere-on-the-pitch-to-anywhere-on-the-pitch-1" class="level3">

From Anywhere on the Pitch, To Anywhere on the Pitch

Now let’s scale up our pass PV grid to all incomplete passes. As with the dynamic successful pass heatmap, hovering over a cell will show PV associated with unsuccessful passes from that point on the pitch.

< details class="hidden"> < summary>Code
{
  const chart = d3.create("div").style("background-color", "8f8f8f")
  const title = chart.append("div").attr("id", "heatmap-title-incomplete-empirical-nested")
  title.append("p").html(`min PV: <span id='pv-min-incomplete-empirical-nested'>0</span>, max PV: <span id='pv-max-incomplete-empirical-nested'>0</span>`)
  chart.append("div").attr("id", "heatmap-incomplete-empirical-nested")
  
  const legendSwatchContainer = chart.append("div")
    .attr("id", "heatmap-legend-incomplete-empirical-nested")
    .style("display", "flex")
    .style("flex-direction", "column")
    .style("align-items", "center")
    .style("width", "100%");
  
  const legendRange = [
    1.1 * d3.min(colorScaleIncompleteRange),
    1.1 * d3.max(colorScaleIncompleteRange)
  ];
  const stepSize = (legendRange[1] - legendRange[0]) / (swatchParams.num - 1);
  const legendSwatches = d3.range(legendRange[0], legendRange[1] + stepSize, stepSize);
  legendSwatches[legendSwatches.length - 1] = legendRange[1];
  
  const totalLegendWidth = swatchParams.width * swatchParams.num;
  
  const swatchRow = legendSwatchContainer.append("div")
    .style("display", "flex")
    .style("justify-content", "center")
    .style("width", "100%");
  
  swatchRow.selectAll("div")
    .data(legendSwatches)
    .enter()
    .append("div")
    .style("width", `${swatchParams.width}px`)
    .style("height", `${swatchParams.height}px`)
    .style("background-color", d => colorScaleIncomplete(d));
  
  const labelRow = legendSwatchContainer.append("div")
    .style("display", "flex")
    .style("justify-content", "center")
    .style("width", `${totalLegendWidth}px`);
  
  labelRow.selectAll("span")
    .data(colorScaleIncompleteRange)
    .enter()
    .append("span")
    .text(d => {
      if (d === d3.min(colorScaleIncompleteRange)) {
        return d + " <=";
      } else if (d === d3.max(colorScaleIncompleteRange)) {
        return ">= " + d;
      }
      return d;
    })
    .style("flex", d => d === 0 ? "1" : null)
    .style("text-align", "center")
  
  return chart.node();
}
Figure 5: A heatmap showing the average possession value (PV) of historically incomplete pass from the hover spot to all areas on the pitch. The relative frequency of successful passes from the center spot to each other cell is shown as a percentage. The highest and lowest PV values across all end points associated with an incomplete pass from the hover point are shown above the pitch. Black cells represent areas to which unsuccessful passes from the hover spot have never been made.
< details class="hidden"> < summary>Code
{  
  const heatmap_incomplete_empirical_nested = d3_soccer.heatmap(pitch)
    .colorScale(d3.scaleLinear().domain([-1, 1]).range(["white", "white"]))
    .enableInteraction(true)
    .onSelect((x, y, v) => {
      const rawMinValue = d3.min(v, d => d.value);
      const rawMaxValue = d3.max(v, d => d.value);
      const minValue = Math.max(rawMinValue, -1);
      const maxValue = Math.min(rawMaxValue, 1);
  
      d3.select('#pv-min-incomplete-empirical-nested').text(minValue.toFixed(3));
      d3.select('#pv-max-incomplete-empirical-nested').text(maxValue.toFixed(3));
      const cells = d3
        .select("#heatmap-incomplete-empirical-nested")
        .selectAll("rect.cell")
        .data(v);
  
      cells.enter()
        .merge(cells)
        .attr("x", d => d.x)
        .attr("y", d => d.y)
        .attr("width", d => d.width)
        .attr("height", d => d.height)
        .style("fill", d => colorScaleIncomplete(+d.value));
        
      d3.select("#heatmap-incomplete-empirical-nested")
        .selectAll("text")
        .remove();
        
      cells.each(function(d, i) {
        const cell = d3.select(this.parentNode);
        const bbox = this.getBBox();
        cell.append("text")
          .attr("x", bbox.x + bbox.width / 2)
          .attr("y", bbox.y + bbox.height / 2)
          .attr("text-anchor", "middle")
          .attr("alignment-baseline", "central")
          .style("-size", "3px")
          .style("pointer-events", "none")
          .text((d.prop * 100).toFixed(1) + "%");
      });
      
      cells.exit().remove();
  
      d3.select("#heatmap-incomplete-empirical-nested")
        .selectAll("rect.cell")
        .data(incomplete_empirical_nested_pv_data);
    })
    .parent_el("#heatmap-incomplete-empirical-nested")
    .interpolate(false);
  
  d3.select("#heatmap-incomplete-empirical-nested")
    .html("")
    .datum(incomplete_empirical_nested_pv_data)
    .call(heatmap_incomplete_empirical_nested);
}

Hovering my mouse over various areas in the middle third of the pitch, I consistently see slightly positive values near the top of the 18-yard box. This is not all that dissimilar from the trend observed with the successful pass pitch, where the passes into the final quarter of the pitch had strong positive PV from basically anywhere. And, like the interactive pitch for completed passes, a 30-yard incomplete pass forward from one’s own 18-yard box doesn’t have the same PV as a 30-yard incomplete pass forward from the half line to the opponent’s 18-yard box. Not all long incomplete passes are judged equally.

< section id="conclusion" class="level1">

Conclusion

Overall, my takeaways are as follows:

  1. Not all long passes add the same kind of value. The pass has to be one that ends up near the box to create non-trivial positive PV.
  2. And, while completed passes will almost always add more value, incomplete passes can also have positive PV when they’re played into dangerous areas.

For those who have built PV models or are very familiar with them in some way, perhaps the latter observation is not an unsurprising result. Indeed, we should want our PV models to see past the outcome of a pass and properly quantify the threat that a through ball can have, whether it’s completed or not.

< section id="caveats" class="level2">

Caveats

< section id="appendix" class="level1">

Appendix

< section id="vaep" class="level2">

VAEP

For those really interested in the details, the PV I’ve been showing is actually the goal probabilities underlying the VAEP framework, but not actually VAEP. In other words, I’ve been showing

where is the th game state and is the team, either home or visiting. But VAEP is actually

where

for action moving the game from state to , and where is defined similarly.

VAEP directly reflects the value added by an action relative to the prior action. For those who have worked with expected threat (xT) before, this is analogous to the “xT created” metric, as described by Singh.

… [T]he point of xT was to come up with a metric that can quantify threat at any location on the pitch… [W]e can value individual player actions in buildup play by computing the difference in xT between the start and end locations. In other words, we will say that an action that moves the ball from location to location has value .

< section id="complete-passes" class="level3">

Complete Passes

Assuming the reader is comfortable with the plotting style and notations before, we now skip to re-creating the dynamic completed pass pitch.

< details class="hidden"> < summary>Code
{
  const chart = d3.create("div").style("background-color", "8f8f8f")
  const title = chart.append("div").attr("id", "heatmap-vaep-title-complete-empirical-nested")
  title.append("p").html(`min VAEP: <span id='vaep-min-complete-empirical-nested'>0</span>, max VAEP: <span id='vaep-max-complete-empirical-nested'>0</span>`)
  chart.append("div").attr("id", "heatmap-vaep-complete-empirical-nested")
  
  const legendSwatchContainer = chart.append("div")
    .attr("id", "heatmap-vaep-legend-complete-empirical-nested")
    .style("display", "flex")
    .style("flex-direction", "column")
    .style("align-items", "center")
    .style("width", "100%");
  
  const legendRange = [
    1.1 * d3.min(colorScaleCompleteRange),
    1.1 * d3.max(colorScaleCompleteRange)
  ];
  const stepSize = (legendRange[1] - legendRange[0]) / (swatchParams.num - 1);
  const legendSwatches = d3.range(legendRange[0], legendRange[1] + stepSize, stepSize);
  legendSwatches[legendSwatches.length - 1] = legendRange[1];
  
  const totalLegendWidth = swatchParams.width * swatchParams.num;
  
  const swatchRow = legendSwatchContainer.append("div")
    .style("display", "flex")
    .style("justify-content", "center")
    .style("width", "100%");
  
  swatchRow.selectAll("div")
    .data(legendSwatches)
    .enter()
    .append("div")
    .style("width", `${swatchParams.width}px`)
    .style("height", `${swatchParams.height}px`)
    .style("background-color", d => colorScaleComplete(d));
  
  const labelRow = legendSwatchContainer.append("div")
    .style("display", "flex")
    .style("justify-content", "center")
    .style("width", `${totalLegendWidth}px`);
  
  
  labelRow.selectAll("span")
    .data(colorScaleCompleteRange)
    .enter()
    .append("span")
    .text(d => {
      if (d === d3.min(colorScaleCompleteRange)) {
        return d + " <=";
      } else if (d === d3.max(colorScaleCompleteRange)) {
        return ">= " + d;
      }
      return d;
    })
    .style("flex", d => d === 0 ? "1" : null)
    .style("text-align", "center")
  
  return chart.node();

}
Figure 6: A heatmap showing the average VAEP of historically completed pass from the hover spot to all areas on the pitch. The relative frequency of successful passes from the hover spot to each other cell is shown as a percentage. The highest and lowest VAEP values across all end points associated with a completed pass from the hover point are shown above the pitch. Black cells represent areas to which successful passes from the hover spot have never been made.
< details class="hidden"> < summary>Code
{  
  const heatmap_vaep_complete_empirical_nested = d3_soccer.heatmap(pitch)
    .colorScale(d3.scaleLinear().domain([-1, 1]).range(["white", "white"]))
    .enableInteraction(true)
    .onSelect((x, y, v) => {
      const rawMinValue = d3.min(v, d => d.value);
      const rawMaxValue = d3.max(v, d => d.value);
      const minValue = Math.max(rawMinValue, -1);
      const maxValue = Math.min(rawMaxValue, 1);
  
      d3.select("#vaep-min-complete-empirical-nested").text(minValue.toFixed(3));
      d3.select("#vaep-max-complete-empirical-nested").text(maxValue.toFixed(3));
      const cells = d3
        .select("#heatmap-vaep-complete-empirical-nested")
        .selectAll("rect.cell")
        .data(v)
  
      cells.enter()
        .merge(cells)
        .attr("x", d => d.x)
        .attr("y", d => d.y)
        .attr("width", d => d.width)
        .attr("height", d => d.height)
        .style("fill", d => colorScaleComplete(+d.value));

      d3.select("#heatmap-vaep-complete-empirical-nested")
        .selectAll("text")
        .remove();
        
      cells.each(function(d, i) {
        const cell = d3.select(this.parentNode);
        const bbox = this.getBBox();
        cell.append("text")
          .attr("x", bbox.x + bbox.width / 2)
          .attr("y", bbox.y + bbox.height / 2)
          .attr("text-anchor", "middle")
          .attr("alignment-baseline", "central")
          .style("-size", "3px")
          .style("pointer-events", "none")
          .text((d.prop * 100).toFixed(1) + "%");
      });
      
      cells.exit().remove();

      d3.select("#heatmap-vaep-complete-empirical-nested")
        .selectAll("rect.cell")
        .data(complete_empirical_nested_vaep_data)

    })
    .parent_el("#heatmap-vaep-complete-empirical-nested")
    .interpolate(false);
  
  d3.select("#heatmap-vaep-complete-empirical-nested")
    .html("")
    .datum(complete_empirical_nested_vaep_data)
    .call(heatmap_vaep_complete_empirical_nested);
}

The big takeaway for me here is that there are a lot more cells on the pitch showing negative values (now VAEP instead of “PV”), especially for passes backward. This makes sense, as the model should see that, on average, such passes put the ball in a less advantageous position.

Recall that our pre-Appendix “PV” pitches account for the probability of conceding. Instances in which the pre-Appendix complete pass pitch shows a negative value indicate a pass start-end pair in which the probability of conceding increases more than the probability of scoring increases (or instances in which the probability of conceding decreases less than the probability of scoring decreases). Naturally, this resulted in a few negative start-to-end pass location combinations, particularly for passes sent very far backward. But now that we’re also accounting for the value of the prior action with VAEP, the pitch shows a lot more negatively valued start-end pairs, particularly for short passes backward.

< section id="incomplete-passes-1" class="level3">

Incomplete Passes

And now we re-create the dynamic pitch for incomplete passes, but showing VAEP instead of goal probability.

< details class="hidden"> < summary>Code
{
  const chart = d3.create("div").style("background-color", "8f8f8f")
  const title = chart.append("div").attr("id", "heatmap-vaep-title-incomplete-empirical-nested")
  title.append("p").html(`min VAEP: <span id='vaep-min-incomplete-empirical-nested'>0</span>, max VAEP: <span id='vaep-max-incomplete-empirical-nested'>0</span>`)
  chart.append("div").attr("id", "heatmap-vaep-incomplete-empirical-nested")
  
  const legendSwatchContainer = chart.append("div")
    .attr("id", "heatmap-vaep-legend-incomplete-empirical-nested")
    .style("display", "flex")
    .style("flex-direction", "column")
    .style("align-items", "center")
    .style("width", "100%");
  
  const legendRange = [
    1.1 * d3.min(colorScaleIncompleteRange),
    1.1 * d3.max(colorScaleIncompleteRange)
  ];
  const stepSize = (legendRange[1] - legendRange[0]) / (swatchParams.num - 1);
  const legendSwatches = d3.range(legendRange[0], legendRange[1] + stepSize, stepSize);
  legendSwatches[legendSwatches.length - 1] = legendRange[1];
  
  const totalLegendWidth = swatchParams.width * swatchParams.num;
  
  const swatchRow = legendSwatchContainer.append("div")
    .style("display", "flex")
    .style("justify-content", "center")
    .style("width", "100%");
  
  swatchRow.selectAll("div")
    .data(legendSwatches)
    .enter()
    .append("div")
    .style("width", `${swatchParams.width}px`)
    .style("height", `${swatchParams.height}px`)
    .style("background-color", d => colorScaleIncomplete(d));
  
  const labelRow = legendSwatchContainer.append("div")
    .style("display", "flex")
    .style("justify-content", "center")
    .style("width", `${totalLegendWidth}px`);
  
  labelRow.selectAll("span")
    .data(colorScaleIncompleteRange)
    .enter()
    .append("span")
    .text(d => {
      if (d === d3.min(colorScaleIncompleteRange)) {
        return d + " <=";
      } else if (d === d3.max(colorScaleIncompleteRange)) {
        return ">= " + d;
      }
      return d;
    })
    .style("flex", d => d === 0 ? "1" : null)
    .style("text-align", "center")
  
  return chart.node();
}
Figure 7: A heatmap showing the average VAEP of historically incomplete pass from the hover spot to all areas on the pitch. The relative frequency of successful passes from the hover spot to each other cell is shown as a percentage. The highest and lowest VAEP values across all end points associated with an incomplete pass from the hover point are shown above the pitch. Black cells represent areas to which unsuccessful passes from the hover spot have never been made.
< details class="hidden"> < summary>Code
{  
  const heatmap_vaep_incomplete_empirical_nested = d3_soccer.heatmap(pitch)
    .colorScale(d3.scaleLinear().domain([-1, 1]).range(["white", "white"]))
    .enableInteraction(true)
    .onSelect((x, y, v) => {
      const rawMinValue = d3.min(v, d => d.value);
      const rawMaxValue = d3.max(v, d => d.value);
      const minValue = Math.max(rawMinValue, -1);
      const maxValue = Math.min(rawMaxValue, 1);
  
      d3.select('#vaep-min-incomplete-empirical-nested').text(minValue.toFixed(3));
      d3.select('#vaep-max-incomplete-empirical-nested').text(maxValue.toFixed(3));
      const cells = d3
        .select("#heatmap-vaep-incomplete-empirical-nested")
        .selectAll("rect.cell")
        .data(v);
  
      cells.enter()
        .merge(cells)
        .attr("x", d => d.x)
        .attr("y", d => d.y)
        .attr("width", d => d.width)
        .attr("height", d => d.height)
        .style("fill", d => colorScaleIncomplete(+d.value));
        
      d3.select("#heatmap-vaep-incomplete-empirical-nested")
        .selectAll("text")
        .remove();
        
      cells.each(function(d, i) {
        const cell = d3.select(this.parentNode);
        const bbox = this.getBBox();
        cell.append("text")
          .attr("x", bbox.x + bbox.width / 2)
          .attr("y", bbox.y + bbox.height / 2)
          .attr("text-anchor", "middle")
          .attr("alignment-baseline", "central")
          .style("-size", "3px")
          .style("pointer-events", "none")
          .text((d.prop * 100).toFixed(1) + "%");
      });
      
      cells.exit().remove();
  
      d3.select("#heatmap-vaep-incomplete-empirical-nested")
        .selectAll("rect.cell")
        .data(incomplete_empirical_nested_vaep_data);
    })
    .parent_el("#heatmap-vaep-incomplete-empirical-nested")
    .interpolate(false);
  
  d3.select("#heatmap-vaep-incomplete-empirical-nested")
    .html("")
    .datum(incomplete_empirical_nested_vaep_data)
    .call(heatmap_vaep_incomplete_empirical_nested);
}

Compared to the pre-Appendix dynamic pitch for incomplete passes, this one shows a lot more negative values. In fact, there is only a very small subset of end points–those near the penalty spot–where an incomplete pass can have positive VAEP, no matter the starting point. So, when accounting for the value of the prior action with VAEP, it appears that incomplete passes only have positive impact in a handful of situations.

< details class="hidden"> < summary>Code
pitch = d3_soccer.pitch()
  .height(300)
  .rotate(false)
  .showDirOfPlay(true)
  .shadeMiddleThird(false)
  .pitchStrokeWidth(0.5)
  .clip([[0, 0], [105, 68]]);
< details class="hidden"> < summary>Code
d3 = require("d3@v5")
< details class="hidden"> < summary>Code
d3_soccer = require("d3-soccer@0.1.0")
< details class="hidden"> < summary>Code
complete_empirical_prop_data  = FileAttachment("complete_empirical_prop_data.json").json()
< details class="hidden"> < summary>Code
complete_empirical_pv_data  = FileAttachment("complete_empirical_pv_data.json").json()
< details class="hidden"> < summary>Code
complete_empirical_nested_pv_data = FileAttachment("complete_empirical_nested_pv_data.json").json()
< details class="hidden"> < summary>Code
incomplete_empirical_pv_data  = FileAttachment("incomplete_empirical_pv_data.json").json()
< details class="hidden"> < summary>Code
incomplete_empirical_nested_pv_data = FileAttachment("incomplete_empirical_nested_pv_data.json").json()
< details class="hidden"> < summary>Code
complete_empirical_nested_vaep_data = FileAttachment("complete_empirical_nested_vaep_data.json").json()
< details class="hidden"> < summary>Code
incomplete_empirical_nested_vaep_data = FileAttachment("incomplete_empirical_nested_vaep_data.json").json()
< details class="hidden"> < summary>Code
colorScaleCompleteRange = [-0.025, 0, 0.025]
< details class="hidden"> < summary>Code
colorScaleIncompleteRange = [-0.025, 0, 0.025]
< details class="hidden"> < summary>Code
colorScaleComplete = d3.scaleLinear()
  .domain(colorScaleCompleteRange)
  .range(["#a6611a", "white", "#018571"]).clamp(true)
< details class="hidden"> < summary>Code
colorScaleIncomplete = d3.scaleLinear()
  .domain(colorScaleIncompleteRange)
  .range(["#d01c8b", "white", "#4dac26"]).clamp(true)
< details class="hidden"> < summary>Code
swatchParams = {
  return {
    width: 40,
    height: 20,
    num: 7
  }
}
< details class="hidden"> < summary>Code
passStartParams = {
  return {
    x: 43.75,
    y: 34
  }
}
< details class="hidden"> < summary>Code
cellParams = {
  return {
    width: 8.75,
    height: 8.5
  }
}
No matching items
< section id="footnotes" class="footnotes footnotes-end-of-document">

Footnotes

  1. Except in this post, where I only briefly mention that I use a PV model.↩︎

  2. My model is trained on 2013/14 – 2023/24 English Premier League data.↩︎

  3. While all PV models are similar conceptually, it’s important to identify how they differ in their target variables. VAEP specifically tries to quantify the difference in the probability of scoring and conceding in the next 10 actions. In contrast, expected threat (xT)–perhaps the most well-known PV model–tries to quantify only the probability of scoring in the next 5 actions, not accounting for the conceding probability, which can undermine the “risk” associated with incomplete passes.↩︎

  4. Do not be alarmed by the small values! Values between 0.02 and 0.02 are very common for PV models. After all, the values represent goal probabilities over sequence of actions, and goals don’t happen all that frequently in soccer.↩︎

  5. We observe lots of missingness near the defender’s box. Such incomplete passes backward would be very illogical no matter the game situation, so it’s not surprising to see that such passes are not observed in our data set.↩︎

  6. Atomic VAEP splits passes into two actions–the pass itself and the reception (or lack of).↩︎

  7. There are over 3.4M total passes in the data set.↩︎

To leave a comment for the author, please follow the link and comment on their blog: Tony's Blog.

R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.
Exit mobile version