What conditioning is, before anything is classified
Conditioning on a variable means comparing only within groups that share its value: within winter, within a tenure band, within the customers who raised a ticket. Every adjustment method does this, including plain regression, and it is a strong operation. Held-fixed variables stop transmitting association along some paths and start transmitting it along others, and which of those two happens is decided entirely by the arrow directions around the variable.
★ What you walk out with tonight. A rule you can apply in ten seconds to any column in any table: name the shape, then act. The reason "it was measured before the treatment" does not make a variable safe, with the price tag on that mistake at Kestrelmark. And a classified column list for one real model, written into a text file beside the graph, so that the next review argues about the shapes rather than about the fit.
LiveThree shapes and nothing else3 min▶
A variable sitting between the treatment and the outcome can be arranged in exactly three ways, and there is no fourth. It can be a common cause of both, in which case arrows point out of it into each. It can be a link in the chain, with an arrow in from the treatment and out to the outcome. Or it can be a common effect, with arrows pointing into it from both sides.
That exhaustiveness is what makes the classification practical. You are not searching an open-ended space of ways a variable could misbehave; you are asking three yes-or-no questions about arrow directions on a graph you already drew. The graph does the hard part, which is why session 1 came first.
The Kestrelmark covariate list arrived in a pull request as six column names in alphabetical order. Sorted that way, device sits next to email and loyalty next to opened, and the ordering carries no information about which of them is free and which is the most expensive mistake on the list. Sorted by shape, the review takes four minutes.
Self-studyThe six Kestrelmark columns and their verdicts3 min▶
- season - confounder. Winter sends more email and sells more. Adjust.
- tenure - confounder. Long accounts get more email and buy more anyway. Adjust.
- loyalty - collider, and a pre-treatment one. A common effect of two unobserved causes. Do not adjust.
- opened - mediator. Carries part of the real effect. Adjust only if the direct effect is the question.
- ticket - collider, post-treatment. A common effect of the email and the spend. Do not adjust.
- device - bystander. No arrows at all. Free either way, and worth knowing that it is free.
Two adjust, three do not, one does not matter. Nothing about the column names, the data types or the correlations with spend would have produced that split.
The file carries a seventh column, recency, and it is deliberately not on that list because it is not one of the three shapes. It is an effect modifier: it sets how much the email is worth to each customer and it shifts their baseline spend, but it does not cause the email, so it opens no back door and needs no adjustment either way. The back-door set is still season and tenure and every graph on this page stands. Sessions 7 and 8 are where it earns its hour.
Three shapes, and what conditioning does to each
11 min liveDraw the same triangle three times and change only which way the arrows point. The three pictures below are that triangle, with the treatment bottom left and the outcome bottom right in all three. The verdict under each one is the whole of the practical rule.
LiveConfounder: conditioning closes a path that was open3 min▶
The shape is X <- Z -> Y. Season causes the email to be sent and independently causes spend to rise, so emailed customers and non-emailed customers differ in more than the email. Association flows from the treatment up the incoming arrow, across the fork and down into the outcome, and that flow is not the effect of anything the campaign did.
- Left alone, the path is open and its contribution is added to whatever the treatment really does. At Kestrelmark the two open back doors add 4.31 on a true effect of 6.23.
- Conditioning closes it. Inside a single season the season cannot vary, so it cannot explain any of the remaining difference in spend. Same for a tenure band.
- This is the only shape where conditioning is unambiguously the right move, and it is the shape most people have in mind when they say "control for it". The trouble starts when the phrase is applied to the other two.
The test is two questions, not one. Does it affect who gets treated, and does it also affect the outcome through some route other than the treatment? A variable that only predicts the outcome is not a confounder, and a variable that only predicts treatment is not one either.
LiveMediator: conditioning removes real effect3 min▶
The shape is X -> M -> Y. Opening the email is not a nuisance sitting between the treatment and the outcome; it is one of the two routes by which the treatment does its work. Holding it fixed compares emailed and non-emailed customers who ended up with the same open status, which subtracts the mechanism from the answer.
- The path carries real effect, which makes this the opposite case from a confounder. Closing it is not cleaning, it is deleting.
- What remains is the direct effect: whatever the treatment does by routes that do not run through the mediator. At Kestrelmark that is the sender name in the inbox.
- So the verdict is conditional on the question. Total effect, leave it out. Direct effect, put it in and say so in the write-up. There is no default that is right for both.
The failure mode is silent. Nothing errors, the fit improves, and the number that comes back is a perfectly good estimate of a quantity nobody asked for.
LiveCollider: conditioning creates association out of nothing3 min▶
The shape is X -> C <- Y, and it is the one that reverses every intuition built on the first two. Formally: in a structure i -> m <- j, conditioning on m makes i and j dependent when they were independent, a result known in epidemiology as Berkson's paradox.
- A collider path is closed to begin with. Two arrows meeting head to head do not transmit association. Nothing needs to be done to it.
- Conditioning opens it. Restricting to a value of the common effect forces its two causes to trade off against each other inside that group.
- The association it creates is entirely manufactured by the analyst. It was not in the population and it will not replicate under a different selection rule.
Self-studyWhy a collider manufactures a link, in Kestrelmark terms4 min▶
Take the ticket flag. Two things push a customer toward raising one: getting the email, which prompts questions, and spending a lot, which also prompts questions. Now look only at the customers who raised a ticket.
Among those customers, learning that somebody did not get the email tells you they probably spent a lot, because something had to have caused the ticket and the email was not it. Learning that somebody did get the email removes the need for the other explanation, so their spend looks ordinary. Inside the ticket group, email and spend now appear negatively related, and that appearance was created by the decision to look inside the group.
- Neither variable changed. The population is exactly as it was; only the window has narrowed.
- Adjustment does this by construction. Putting ticket on the right-hand side of a regression is the same operation as looking within ticket groups, done for every value at once.
- It also happens without a regression. Any filter that removes rows on a common effect does it: analysing only customers who contacted support, only accounts that survived, only applications that were approved.
The selection version is more common than the adjustment version and much harder to see, because it happens in the WHERE clause rather than the covariate list. A cohort defined as "customers who raised a ticket in the period" is a conditioned collider whether or not anybody typed the word ticket into a model.
The two that fool people
8 min liveA post-treatment collider like the ticket flag is the costliest column here, at -3.64, and it is at least suspicious on sight: it happened after the campaign. The two cases below pass that check and still do damage, which is why they are the ones that end up in shipped models.
LiveThe pre-treatment collider, and why timing proves nothing4 min▶
Kestrelmark's loyalty score is computed from behaviour that all happened before the campaign went out. Every reviewer who has ever been burned by a leaked post-treatment feature will wave it through on that basis, and every one of them is wrong here.
The score is a common effect of two things nobody measured. Call them u1 and u2: u1 is the disposition that makes somebody both a campaign target and a certain kind of loyal, and u2 is whatever makes somebody both loyal and a spender. So u1 -> loyalty <- u2, while u1 -> email and u2 -> spend. The path email <- u1 -> loyalty <- u2 -> spend contains a collider in the middle and is therefore closed while nobody touches it.
- Conditioning on loyalty opens that path, connecting the email to spend through two variables that are not in the warehouse and cannot be adjusted away.
- It happens despite loyalty preceding the treatment. The shape is
A <- U1 -> C <- U2 -> Yand the middle of an M has nothing to do with when C was recorded. - The price is measured. Adding loyalty to the correct set moves the estimate from 6.56 to 5.26, a bias of -0.97 against a true effect of 6.23. Timing says nothing about the size either: the post-treatment collider on the same graph costs -3.64, the biggest single harm of the four additions.
Replace the timing check with a shape check. "Was it measured before the treatment" is a useful screen for leakage and tells you nothing about bias. The question that matters is whether two arrows point into the variable, and that is answered on the graph.
LiveDescendants: a proxy for a collider is still a collider3 min▶
The formal statement includes it explicitly: conditioning on m or any descendant of m makes i and j dependent. Anything caused by a collider inherits most of its information, and conditioning on the child partially conditions on the parent.
- "We used a proxy instead" is not a fix. A support-contact rate, a satisfaction survey sent to ticket raisers, a churn-risk flag built partly from ticket volume: all of them are downstream of the ticket and all of them leak the same association.
- Nor is a coarser version. Bucketing a collider into three bands weakens the effect rather than removing it, and the residual bias is unknown without the graph.
- Nor is a composite score. If any input to an engineered feature is a collider or a descendant of one, the feature is contaminated, and this is how the problem gets into models that never mention the offending column.
The practical consequence is that classification has to run on the lineage, not on the column name. A feature is safe when everything feeding it is safe.
LiveThe mediator is not a mistake, it is a different question3 min▶
Adding the open flag to the correct set moves the estimate from 6.56 to 5.48. That looks like the same kind of failure as the loyalty score, and at -0.75 against that column's -0.97 it is even about the same size. It is not the same thing at all.
The model that generated the data has a mean direct effect of 5.16 written into it: the part of the email's work that does not run through opening. Conditioning on the open removes the mediated part and leaves that direct effect, and 5.48 is a good estimate of 5.16. The arithmetic did what it was asked. The specification asked the wrong thing.
- Total effect, 6.23 - what happens if the email is sent. This answers "should we run the campaign", and it is the estimand for almost every business decision.
- Direct effect, mean 5.16 - what happens if the email is sent and nobody opens it. This answers "how much of the lift survives a bad subject line", which is a genuine question for a different team.
- The bench cannot tell which you wanted. Nor can a reviewer, unless the estimand is written down before the covariate list.
The commonest route to this is an engagement feature added for accuracy. Open rate, session count, page views: all downstream of the treatment, all improve the fit, all quietly convert a campaign lift number into a direct-effect number that nobody labels as such.
Self-studyThe bystander, and why it matters that it is harmless3 min▶
Device has no arrows in the Kestrelmark graph. Adding it to the correct set leaves the estimate at 6.56 and the bias at +0.33, matching the back-door set to 0.01.
That result is worth as much as the harmful ones, because it removes the escape route of treating every extra covariate as risky. If all additions cost something, "keep the list short" would be sufficient advice and no graph would be needed. They do not. One of the four additions is free, three are expensive, and the four look identical in a schema browser. The classification is what separates them and there is no cheaper substitute.
- In finite samples a bystander is not quite free - it spends a degree of freedom and can move the estimate. At 5,000 rows here it moves it by 0.01.
- A variable that predicts the outcome and not the treatment is also generally safe and can tighten things up. The recency segment is exactly that: it moves spend, it does not move who gets the email, and so it carries no adjustment duty. Precision is a different topic from bias, and the bench measures only bias.
Classify every column, then price the mistakes
★ 18 min · everyone buildsKestrelmark's table is one row per customer with the six observable columns plus the outcome. Work through it in whichever of the two languages you live in; the arithmetic is the same and neither one needs a library beyond numpy.
Write the six roles down before running anything. For each column ask the three questions in order: does it cause the email, does it cause spend, is it caused by either. Two causes out means confounder, one in and one out means mediator, two causes in means collider, nothing means bystander.
Get the naive gap in SQL. One query, no model, and it should give 10.54. This is the number a dashboard reports.
Run the back-door regression with season and tenure only. The estimate moves from 10.54 to 6.56, and the bias from +4.31 to +0.33. The +0.33 left over is noise rather than bias, and the way to tell is to change the seed: across five of them the back-door bias comes out +0.33, +0.17, -0.38, +0.19, -0.01, mean +0.06, changing sign. Every contaminated set below keeps its sign in all five.
Add loyalty on its own and record it. 5.26, bias -0.97. Say out loud that this variable was measured before the treatment, because that is the sentence you are unlearning.
Untick loyalty, add opened. 5.48, bias -0.75. Write beside it "direct effect, mean 5.16 by construction" so that the record distinguishes a wrong answer from a different question.
Untick opened, add ticket. 2.59, bias -3.64. Same shape as loyalty, later in time, the most expensive of the four by a wide margin, and the one a reviewer would actually catch.
Add device. 6.56, unchanged. Sit with the fact that the four additions cost 0.97, 0.75, 3.64 and nothing, that the worst of them is the one that arrived last, and that the schema gives no hint which is which.
Write the classification into a text file beside the DAG from session 1, one line per column, role and verdict and a reason. That file is the artifact reviewers should be reading instead of the covariate list.
Query 3 is the whole collider lesson in six lines. Splitting by ticket is not an analysis choice that happens to be inconvenient; it is the same operation a regression performs when ticket is on the right-hand side. If the gap inside the groups disagrees with the gap overall, ask which shape you just conditioned on before deciding which number to believe.
Keep the roles file in version control next to the DAG. A covariate list changes in a diff that shows one added line and no reason. A roles file forces whoever adds the line to name a shape, and naming a shape is the point at which most bad additions stop.
Before session 3
30 min- Take the covariate list from a model your team ships and write a roles file for it: column, shape, verdict, reason. Count how many rows you had to mark unknown.
- Find one variable in that list that is caused by the treatment. There is usually one and it is usually there because it improved the fit.
- Check the lineage of every engineered feature. If any input is a collider or a descendant of one, mark the feature contaminated and note which input did it.
- Look at the cohort definition, not just the model. Any filter on a variable caused by both the treatment and the outcome is a conditioned collider hiding in a
WHEREclause. - Write one sentence saying whether the estimand is the total effect or the direct effect, and bring it to session 3, where the question becomes whether any set at all can deliver it.
Three questions
A reviewer approves the loyalty score for the adjustment set because it was computed weeks before the campaign went out. Adding it moves the estimate from 6.56 to 5.26. What did the timing argument miss?
The damaging shape is two arrows pointing in, and an arrival timestamp says nothing about arrow directions. The pre-treatment collider costs 0.97 here and the post-treatment one costs 3.64, so timing predicts neither the presence of the damage nor its size.
Adjusting for whether the customer opened the email returns 5.48 against a true total effect of 6.23 and a mean direct effect of 5.16 written into the model. How should that be reported?
The mediator carries real effect, so removing it subtracts a genuine part of the answer rather than contaminating it. The number is right; the question changed, and only a written estimand catches that.
A team drops the ticket flag from the model but keeps a support-contact score built from ticket volume. What happens?
The formal statement covers m or any descendant of m. Classification runs on lineage, not on column names, and a feature is safe only when everything feeding it is safe.