11  Segmented Validation

A single result for the whole table can hide important detail. A check that fails on 2 percent of rows might be failing evenly across the table, or it might be failing on a quarter of the rows in one region and nowhere else. Those are very different situations, and you would respond to them differently. Segmented validation shows you which one you have by running a check separately for each group in the data and reporting a result for each group.

To segment a check, add the segments= argument to it. Most checks that look at individual values or rows support it. With segments=, one step in your plan becomes one step per segment, each with its own counts and its own pass or fail result. This chapter covers the different ways to define segments: a column name, a column with a chosen set of values, and seg_group() for combining several values into one segment. It then covers segmenting on several columns at once, how thresholds work with segments, and which checks support segmentation. We use game_revenue throughout, since its item_type and acquisition columns give us natural groups.

game_revenue = pb.load_dataset(dataset="game_revenue", tbl_type="polars")

We start with the simplest form, which splits a check by the values of one column.

11.1 Segmenting by a column

Suppose we want to flag unusually large transactions, on the idea that any single item earning 100 or more is worth a look. Run against the whole table, the check finds only a few failures.

(
    pb.Validate(data=game_revenue)
    .col_vals_lt(columns="item_revenue", value=100)
    .interrogate()
)
STEP COLUMNS VALUES TBL EVAL UNITS PASS FAIL W E C EXT
#4CA64C66 1
col_vals_lt
col_vals_lt()
item_revenue 100 ✓ 2000 1994
1.00
6
0.00
— — —

Six of the 2,000 rows fail. That is a low enough rate that you might put it down to a few big spenders and move on, and the result says nothing about where those six rows come from. If we pass a column name to segments=, the same check runs once for each value of item_type, so once for ad revenue and once for in-app purchases.

(
    pb.Validate(data=game_revenue)
    .col_vals_lt(columns="item_revenue", value=100, segments="item_type")
    .interrogate()
)
STEP COLUMNS VALUES TBL EVAL UNITS PASS FAIL W E C EXT
#4CA64C 1
SEGMENT  item_type / ad
col_vals_lt
col_vals_lt()
item_revenue 100 ✓ 1553 1553
1.00
0
0.00
— — — —
#4CA64C66 2
SEGMENT  item_type / iap
col_vals_lt
col_vals_lt()
item_revenue 100 ✓ 447 441
0.99
6
0.01
— — —

The one step is now two, each labeled with its segment. The ad segment covers all 1,553 ad rows and passes. The iap segment covers the 447 in-app purchase rows and has all six failures. That makes sense once you know that ad revenue in this table never goes above 1.872, while in-app purchases go as high as 142.99. So instead of “six unusual rows somewhere”, the report tells you that every large transaction is an in-app purchase.

Reading a segmented report is the same as reading any other report, except that each step names the segment it covers. A failure tells you which group the failing rows belong to, and a segment that passes is useful too, since it tells you the problem is not there.

11.2 Choosing which segments to create

Segmenting on a column with many distinct values creates a step for every one of them, which is not always what you want. If only a few groups interest you, you can pass a tuple to segments= with the column name and a list of the values you want. Here we check only the organic and apple acquisition channels.

(
    pb.Validate(data=game_revenue)
    .col_vals_gt(columns="item_revenue", value=0, segments=("acquisition", ["organic", "apple"]))
    .interrogate()
)
STEP COLUMNS VALUES TBL EVAL UNITS PASS FAIL W E C EXT
#4CA64C 1
SEGMENT  acquisition / organic
col_vals_gt
col_vals_gt()
item_revenue 0 ✓ 1038 1038
1.00
0
0.00
— — — —
#4CA64C 2
SEGMENT  acquisition / apple
col_vals_gt
col_vals_gt()
item_revenue 0 ✓ 25 25
1.00
0
0.00
— — — —

The report has exactly two steps, one for organic (1,038 rows) and one for apple (25 rows), and both pass. The other four acquisition channels do not appear because we did not ask for them. This form is useful when a column has many categories but you only care about a few, such as two channels you are investigating.

11.3 Combining values into one segment with seg_group

Sometimes you want to treat several values as one group, such as a set of acquisition channels that are all unpaid. The seg_group() helper combines a list of values into a single segment, so the check runs once across all of them instead of once per value. Here we group the organic and facebook channels.

(
    pb.Validate(data=game_revenue)
    .col_vals_gt(
        columns="item_revenue", value=0,
        segments=("acquisition", pb.seg_group(["organic", "facebook"])),
    )
    .interrogate()
)
STEP COLUMNS VALUES TBL EVAL UNITS PASS FAIL W E C EXT
#4CA64C 1
SEGMENT  acquisition / ['organic', 'facebook']
col_vals_gt
col_vals_gt()
item_revenue 0 ✓ 1206 1206
1.00
0
0.00
— — — —

The result is one step covering 1,206 rows (1,038 organic plus 168 facebook), rather than two separate steps. If you pass a list of lists to seg_group(), you get several combined segments at once. For example, pb.seg_group([["organic", "facebook"], ["google", "apple"]]) produces two steps, one with 1,206 rows and one with 413. All the values in a group must be the same type. Mixing a string and a number in one group raises a TypeError.

11.4 Segmenting across several columns

So far we have segmented on one column at a time. You can also pass a list of column names to segments=, which segments on each column separately within the same step. This is not a cross-product of the columns. Each column gets its own set of segments.

(
    pb.Validate(data=game_revenue)
    .col_vals_lt(columns="item_revenue", value=100, segments=["item_type", "acquisition"])
    .interrogate()
)
STEP COLUMNS VALUES TBL EVAL UNITS PASS FAIL W E C EXT
#4CA64C 1
SEGMENT  item_type / ad
col_vals_lt
col_vals_lt()
item_revenue 100 ✓ 1553 1553
1.00
0
0.00
— — — —
#4CA64C66 2
SEGMENT  item_type / iap
col_vals_lt
col_vals_lt()
item_revenue 100 ✓ 447 441
0.99
6
0.01
— — —
#4CA64C 3
SEGMENT  acquisition / apple
col_vals_lt
col_vals_lt()
item_revenue 100 ✓ 25 25
1.00
0
0.00
— — — —
#4CA64C 4
SEGMENT  acquisition / crosspromo
col_vals_lt
col_vals_lt()
item_revenue 100 ✓ 114 114
1.00
0
0.00
— — — —
#4CA64C 5
SEGMENT  acquisition / facebook
col_vals_lt
col_vals_lt()
item_revenue 100 ✓ 168 168
1.00
0
0.00
— — — —
#4CA64C66 6
SEGMENT  acquisition / google
col_vals_lt
col_vals_lt()
item_revenue 100 ✓ 388 387
1.00
1
0.00
— — —
#4CA64C66 7
SEGMENT  acquisition / organic
col_vals_lt
col_vals_lt()
item_revenue 100 ✓ 1038 1034
1.00
4
0.00
— — —
#4CA64C66 8
SEGMENT  acquisition / other_campaign
col_vals_lt
col_vals_lt()
item_revenue 100 ✓ 267 266
1.00
1
0.00
— — —

The one step becomes eight: two for the item_type values and six for the acquisition channels. The item_type segments show the same result as before, with all six failures in iap. The acquisition segments show the same six failures spread over three channels: four in organic, one in google, and one in other_campaign. So the large purchases are not coming from one particular channel. If you want every combination of item type and channel, Pointblank does not create that for you. Instead, you can add a column that combines the two and segment on that.

Because one step can turn into many, be careful about which columns you segment on. Segmenting on a column with thousands of distinct values, like an ID column, produces thousands of steps and a report nobody can read, so stick to columns with a manageable number of groups. Also check that the values you name actually occur in the data. If you name a value that is not there, Pointblank still creates a step for it, and that step covers zero rows and shows an evaluation error in the report.

11.5 How segments interact with thresholds

Each segment is a full validation step, so thresholds apply to each segment on its own rather than to the check as a whole. This is often the main reason to segment. A failure rate that looks fine across the whole table can be much higher within one group. Our revenue check has 6 failures out of 2,000 rows, a rate of 0.003, which is well under a warning threshold of 0.01.

(
    pb.Validate(data=game_revenue, thresholds=pb.Thresholds(warning=0.01))
    .col_vals_lt(columns="item_revenue", value=100)
    .interrogate()
)
STEP COLUMNS VALUES TBL EVAL UNITS PASS FAIL W E C EXT
#4CA64C66 1
col_vals_lt
col_vals_lt()
item_revenue 100 ✓ 2000 1994
1.00
6
0.00
○ — —

The step does not reach the warning level, since 0.003 is below 0.01, so this plan would not raise a warning. Now let’s segment the same check by item_type with the same threshold.

(
    pb.Validate(data=game_revenue, thresholds=pb.Thresholds(warning=0.01))
    .col_vals_lt(columns="item_revenue", value=100, segments="item_type")
    .interrogate()
)
STEP COLUMNS VALUES TBL EVAL UNITS PASS FAIL W E C EXT
#4CA64C 1
SEGMENT  item_type / ad
col_vals_lt
col_vals_lt()
item_revenue 100 ✓ 1553 1553
1.00
0
0.00
○ — — —
#AAAAAA 2
SEGMENT  item_type / iap
col_vals_lt
col_vals_lt()
item_revenue 100 ✓ 447 441
0.99
6
0.01
● — —

The ad segment still passes. The iap segment has the same six failures, but now out of 447 rows instead of 2,000, which is a rate of about 0.013. That is above 0.01, so the segment is marked at the warning level. The same six rows that were an acceptable rate for the whole table now trigger a warning, because they are measured against the group they belong to. This is how segmented thresholds catch a problem that is serious within one group but rare across the table.

11.6 Where segmentation applies

Segmentation works with checks that evaluate the data row by row, which is most of them. Nearly all of the col_vals_* methods accept segments= (the exception is col_vals_in_table()). So do rows_distinct() and rows_complete() from Chapter 8, the three row-level missing-value methods from Chapter 10, and prompt() from Chapter 17. All of these produce a result for each row, which is what makes it possible to split the rows into groups.

The checks that do not accept segments= are mostly the ones that reduce a column or table to a single result. The aggregate methods of Chapter 7 compute one sum, average, or standard deviation for the whole column, and the table-level checks of Chapter 9 look at the table as a whole, so there are no row results to divide up. The col_pct_null() and col_pct_missing() methods fall into this group too, as do conjointly() and specially().

WarningAggregates and table checks do not segment

The segments= argument is not available on the aggregate methods (col_sum_*, col_avg_*, and col_sd_*) or on the table-level checks. To get a result for each group, such as a sum or a standard deviation for each device or region, you validate each group as its own table. The IoT playbook in Chapter 34 uses this approach to check a minimum variance for each sensor.

For the checks that do support it, segmentation fits in with the rest of a plan. Each segment is a full step, so it has its own threshold results and counts toward the quality scores in Chapter 15 like any other step. A plan can mix segmented and unsegmented steps freely.

11.7 Bringing it together

The plan below combines an unsegmented completeness check with a segmented revenue check.

(
    pb.Validate(data=game_revenue, tbl_name="game_revenue")
    .col_vals_not_null(columns="player_id")
    .col_vals_lt(columns="item_revenue", value=100, segments="item_type")
    .interrogate()
)
STEP COLUMNS VALUES TBL EVAL UNITS PASS FAIL W E C EXT
#4CA64C 1
col_vals_not_null
col_vals_not_null()
player_id — ✓ 2000 2000
1.00
0
0.00
— — — —
#4CA64C 2
SEGMENT  item_type / ad
col_vals_lt
col_vals_lt()
item_revenue 100 ✓ 1553 1553
1.00
0
0.00
— — — —
#4CA64C66 3
SEGMENT  item_type / iap
col_vals_lt
col_vals_lt()
item_revenue 100 ✓ 447 441
0.99
6
0.01
— — —

The report starts with the completeness step, which confirms that every row has a player ID, and then shows the revenue check split into its two segments. All of the large-transaction failures are in the iap segment. Mixing the two kinds of steps lets you check some things across the whole table and others group by group.

11.8 Summary

Segmented validation splits a check into one result per group, so a problem that is concentrated in one part of the data shows up on its own line instead of being diluted across the whole table. You turn it on with the segments= argument. It accepts a column name, which creates a segment for every distinct value, or a (column, values) tuple, which creates segments only for the values you list. Inside that tuple, seg_group() combines several values into one segment, and a list of lists gives you several combined segments. A list of column names segments on each column separately. To keep reports readable, segment on columns with a manageable number of groups and name only values that actually occur.

Because each segment is a complete step, thresholds apply to each group on its own. A failure rate that stays under a threshold across the whole table can cross it within the group where the failures are concentrated. Segmentation is available on the checks that evaluate rows one at a time, and not on aggregate or table-level checks. The next chapter, Chapter 12, covers checks for rules that no built-in method handles, where you write the logic yourself.