|
| 1 | +Counterfactual Reasoning |
| 2 | +========================= |
| 3 | + |
| 4 | +.. note:: |
| 5 | + |
| 6 | + Find the full, executable code `here <https://github.com/lab-v2/pyreason/blob/main/examples/counterfactual_tutorial_ex.py>`_ |
| 7 | + |
| 8 | +This tutorial extends the cybersecurity inconsistency tutorial. The |
| 9 | +inconsistency tutorial asked: *"do these facts conflict?"* This |
| 10 | +tutorial asks the opposite question: *"if I changed something, would |
| 11 | +the conclusions still hold?"* That is what a counterfactual is -- a |
| 12 | +"what if" question answered by re-running reasoning on a modified |
| 13 | +input. |
| 14 | + |
| 15 | +PyReason has no built-in counterfactual operator. We implement them |
| 16 | +manually: run reasoning on the original graph, run again on a modified |
| 17 | +copy, and compare the two final states. The differences tell us which |
| 18 | +conclusions depended on the change. |
| 19 | + |
| 20 | +.. note:: |
| 21 | + |
| 22 | + This tutorial reuses the cybersecurity knowledge graph from the |
| 23 | + *Cybersecurity Inconsistency* tutorial. Familiarity with that tutorial |
| 24 | + is assumed. |
| 25 | + |
| 26 | +What a Grounding Is |
| 27 | +------------------- |
| 28 | + |
| 29 | +Before the demos: a brief note on terminology, since the rest of the |
| 30 | +tutorial leans on this concept. |
| 31 | + |
| 32 | +A rule like ``at_risk(X) <- runs(X, Y), has_cve(Y, Z)`` does not "fire" |
| 33 | +once. It fires once for each combination of nodes that satisfies the |
| 34 | +body. Each such combination is called a **grounding**. In the |
| 35 | +cybersecurity graph there are three groundings of this rule, one for |
| 36 | +each asset: |
| 37 | + |
| 38 | +============= =========================== ============================ |
| 39 | +Grounding for X = ... Y = ..., Z = ... |
| 40 | +============= =========================== ============================ |
| 41 | +1 ``web_server`` ``sudo_1_9_5p1``, ``cve_2021_3156`` |
| 42 | +2 ``workstation_1`` ``linux_kernel_5_1``, ``cve_2022_0185`` |
| 43 | +3 ``dev_server`` ``openssl_3_0_1``, ``cve_2022_26923`` |
| 44 | +============= =========================== ============================ |
| 45 | + |
| 46 | +Each grounding is independent. A counterfactual perturbation can add, |
| 47 | +modify, or remove specific groundings -- adding an edge or fact can |
| 48 | +introduce new groundings, modifying a bound can change whether a |
| 49 | +grounding's threshold is met, and removing an edge or fact eliminates |
| 50 | +any grounding that depended on it. The others fire normally. This is |
| 51 | +why counterfactual perturbations have such localized effects -- they |
| 52 | +target specific groundings without touching the rest. |
| 53 | + |
| 54 | +The Three Demos |
| 55 | +--------------- |
| 56 | + |
| 57 | +The script ``counterfactual_tutorial_ex.py`` walks through three |
| 58 | +counterfactuals: |
| 59 | + |
| 60 | +- **Demo 1** -- remove a graph edge. Show that one grounding of |
| 61 | + ``exposure_rule`` disappears, and the cascade collapses for the |
| 62 | + affected asset. |
| 63 | +- **Demo 2** -- inject a contradicting fact. Show that PyReason |
| 64 | + detects the conflict and reports an inconsistency. |
| 65 | +- **Demo 3** -- start from a graph that *already has* an inconsistency. |
| 66 | + Counterfactually remove a candidate cause and check whether the |
| 67 | + inconsistency disappears. |
| 68 | + |
| 69 | +Each run writes a CSV trace alongside the script. Trace rows |
| 70 | +are referenced inline below. |
| 71 | + |
| 72 | +Demo 1: Remove a Graph Edge |
| 73 | +--------------------------- |
| 74 | + |
| 75 | +**Question:** if ``web_server`` did not run ``sudo_1_9_5p1``, would it |
| 76 | +still be classified as at risk? |
| 77 | + |
| 78 | +The ``exposure_rule`` says: a host is at risk if it runs some software |
| 79 | +that has a CVE. In PyReason, a graph edge with an attribute is treated |
| 80 | +as a fact -- the ``runs=1`` edge between ``web_server`` and |
| 81 | +``sudo_1_9_5p1`` becomes the fact ``runs(web_server, sudo_1_9_5p1)`` |
| 82 | +during reasoning. Removing the edge removes that fact, so the |
| 83 | +grounding of ``exposure_rule`` for ``web_server`` no longer has |
| 84 | +anything to bind ``Y`` to and cannot fire. |
| 85 | + |
| 86 | +In the baseline trace, the rule fires three times -- once per asset. |
| 87 | +The relevant rows are:: |
| 88 | + |
| 89 | + Time Op Node Label Old Bound New Bound Caused By Consistent Clause-1 |
| 90 | + 0 1 web_server at_risk [0.0,1.0] [1.0,1.0] exposure_rule True [('web_server', 'sudo_1_9_5p1')] |
| 91 | + 0 1 workstation_1 at_risk [0.0,1.0] [1.0,1.0] exposure_rule True [('workstation_1', 'linux_kernel_5_1')] |
| 92 | + 0 1 dev_server at_risk [0.0,1.0] [1.0,1.0] exposure_rule True [('dev_server', 'openssl_3_0_1')] |
| 93 | + |
| 94 | +The ``Clause-1`` column shows which graph elements satisfied each |
| 95 | +grounding's body. After we remove the edge, the counterfactual trace |
| 96 | +contains only the second and third rows. The ``web_server`` row is |
| 97 | +gone -- because the edge it depended on no longer exists:: |
| 98 | + |
| 99 | + Time Op Node Label Old Bound New Bound Caused By Consistent Clause-1 |
| 100 | + 0 1 workstation_1 at_risk [0.0,1.0] [1.0,1.0] exposure_rule True [('workstation_1', 'linux_kernel_5_1')] |
| 101 | + 0 1 dev_server at_risk [0.0,1.0] [1.0,1.0] exposure_rule True [('dev_server', 'openssl_3_0_1')] |
| 102 | + |
| 103 | +The three downstream rules each require ``at_risk`` to have fired for |
| 104 | +the same node before they can produce a grounding: |
| 105 | + |
| 106 | +- ``vulnerability_rule``: ``vulnerable(X):[0.8, 1.0] <- at_risk(X)`` |
| 107 | +- ``compromise_rule``: ``compromised(X):[0.8, 1.0] <- vulnerable(X):[0.5, 1.0]`` |
| 108 | +- ``unpatched_rule``: ``patch_confidence(X):[0.0, 0.2] <- compromised(X):[0.5, 1.0]`` |
| 109 | + |
| 110 | +With ``at_risk(web_server)`` absent from the counterfactual trace, |
| 111 | +none of them have a valid grounding for ``web_server``. |
| 112 | + |
| 113 | +Diff vs. baseline (final state of each run, side by side): |
| 114 | + |
| 115 | +================ ================= ================= ================== |
| 116 | +node predicate baseline counterfactual |
| 117 | +================ ================= ================= ================== |
| 118 | +web_server at_risk [1.0, 1.0] (none) |
| 119 | +web_server vulnerable [0.8, 1.0] (none) |
| 120 | +web_server compromised [0.8, 1.0] (none) |
| 121 | +web_server patch_confidence [0.0, 0.2] (none) |
| 122 | +================ ================= ================= ================== |
| 123 | + |
| 124 | +``workstation_1`` and ``dev_server`` are unaffected -- their groundings |
| 125 | +remain intact. One edge removed, four conclusions lost, all on a single |
| 126 | +asset. That is the basic shape of a counterfactual: a small input |
| 127 | +change has a localized but compounded effect downstream. |
| 128 | + |
| 129 | +Demo 2: Inject a Contradicting Fact |
| 130 | +----------------------------------- |
| 131 | + |
| 132 | +**Question:** what happens if we assert that ``workstation_1`` is |
| 133 | +*definitely not* at risk, even though the graph would normally infer |
| 134 | +that it is? |
| 135 | + |
| 136 | +We add a fact ``at_risk(workstation_1):[0.0, 0.0]`` -- meaning "I am |
| 137 | +100% certain this is false." The graph still says it should be |
| 138 | +``[1.0, 1.0]`` (true), so two sources will disagree. |
| 139 | + |
| 140 | +In the trace, the injected fact lands first:: |
| 141 | + |
| 142 | + Time Op Node Label Old Bound New Bound Caused By Consistent |
| 143 | + 0 0 workstation_1 at_risk [0.0,1.0] [0.0,0.0] cf_not_at_risk True |
| 144 | + |
| 145 | +Then the ``exposure_rule`` fires and tries to write ``[1.0, 1.0]``. |
| 146 | +The two bounds do not overlap, so PyReason flags an inconsistency:: |
| 147 | + |
| 148 | + Time Op Node Label Old Bound New Bound Caused By Consistent |
| 149 | + 0 1 workstation_1 at_risk [0.0,0.0] [0.0,1.0] exposure_rule False |
| 150 | + |
| 151 | + Inconsistency Message: |
| 152 | + "Inconsistency occurred. Conflicting bounds for at_risk(workstation_1). |
| 153 | + Update from [0.000, 0.000] to [1.000, 1.000] is not allowed. |
| 154 | + Setting bounds to [0,1] and static=True for this timestep." |
| 155 | + |
| 156 | +For that one timestep, the bound is set to ``[0.0, 1.0]`` (fully |
| 157 | +unknown) and marked static. At the next timestep, the injected fact |
| 158 | +re-asserts itself (its time window covers all timesteps), pulling the |
| 159 | +bound back to ``[0.0, 0.0]``. The final state shows ``[0.0, 0.0]`` -- |
| 160 | +the injection wins by being re-asserted. |
| 161 | + |
| 162 | +**Why the cascade breaks downstream:** |
| 163 | + |
| 164 | +The next rule in the chain is ``vulnerability_rule``:: |
| 165 | + |
| 166 | + vulnerable(X):[0.8, 1.0] <- at_risk(X) |
| 167 | + |
| 168 | +In the baseline trace, this rule fires three times (one grounding per |
| 169 | +asset):: |
| 170 | + |
| 171 | + Time Op Node Label Caused By Consistent Clause-1 |
| 172 | + 0 2 web_server vulnerable vulnerability_rule True ['web_server'] |
| 173 | + 0 2 workstation_1 vulnerable vulnerability_rule True ['workstation_1'] |
| 174 | + 0 2 dev_server vulnerable vulnerability_rule True ['dev_server'] |
| 175 | + |
| 176 | +In the counterfactual trace, only two rows appear -- the |
| 177 | +``workstation_1`` grounding is missing:: |
| 178 | + |
| 179 | + 0 2 web_server vulnerable vulnerability_rule True ['web_server'] |
| 180 | + 0 2 dev_server vulnerable vulnerability_rule True ['dev_server'] |
| 181 | + |
| 182 | +That grounding does not fire because its body |
| 183 | +(``at_risk(workstation_1)``) is held at ``[0.0, 0.0]``, which fails |
| 184 | +the rule's threshold check. With no ``vulnerable(workstation_1)``, |
| 185 | +``compromise_rule`` cannot fire for ``workstation_1`` either, and the |
| 186 | +chain breaks one grounding at a time. |
| 187 | + |
| 188 | +The same three downstream rules from Demo 1 apply here: |
| 189 | + |
| 190 | +- ``vulnerability_rule``: ``vulnerable(X):[0.8, 1.0] <- at_risk(X)`` |
| 191 | +- ``compromise_rule``: ``compromised(X):[0.8, 1.0] <- vulnerable(X):[0.5, 1.0]`` |
| 192 | +- ``unpatched_rule``: ``patch_confidence(X):[0.0, 0.2] <- compromised(X):[0.5, 1.0]`` |
| 193 | + |
| 194 | +Diff vs. baseline: |
| 195 | + |
| 196 | +================ ================= ================= ================== |
| 197 | +node predicate baseline counterfactual |
| 198 | +================ ================= ================= ================== |
| 199 | +workstation_1 at_risk [1.0, 1.0] [0.0, 0.0] |
| 200 | +workstation_1 vulnerable [0.8, 1.0] (none) |
| 201 | +workstation_1 compromised [0.8, 1.0] (none) |
| 202 | +workstation_1 patch_confidence [0.0, 0.2] (none) |
| 203 | +================ ================= ================= ================== |
| 204 | + |
| 205 | +A single injected fact eliminated three downstream groundings, exactly |
| 206 | +as in Demo 1 -- but via a different mechanism. In Demo 1 a graph edge |
| 207 | +was removed; in Demo 2 a fact was added that produced an inconsistency, |
| 208 | +and the resolved bound was incompatible with downstream rules. |
| 209 | + |
| 210 | +Demo 3: Diagnose an Existing Inconsistency |
| 211 | +------------------------------------------ |
| 212 | + |
| 213 | +Demos 1 and 2 perturbed an otherwise-consistent baseline. Demo 3 is |
| 214 | +different: **the baseline already contains an inconsistency** before |
| 215 | +any perturbation. The counterfactual question is: which fact is |
| 216 | +causing it? |
| 217 | + |
| 218 | +**The baseline inconsistency:** |
| 219 | + |
| 220 | +The four-rule chain ends with ``unpatched_rule``, which says: a |
| 221 | +compromised host has low patch confidence:: |
| 222 | + |
| 223 | + patch_confidence(X):[0.0, 0.2] <- compromised(X):[0.5, 1.0] |
| 224 | + |
| 225 | +This rule fires for all three assets. Separately, the fact |
| 226 | +``dev_patch_db_fact`` asserts ``patch_confidence(dev_server):[0.9, 1.0]`` |
| 227 | +-- "the patch database says dev_server is well patched." |
| 228 | + |
| 229 | +For ``dev_server``, both writes target the same atom with |
| 230 | +non-overlapping bounds. Looking at the baseline trace:: |
| 231 | + |
| 232 | + Time Op Node Label Old Bound New Bound Caused By Consistent |
| 233 | + 0 0 dev_server patch_confidence [0.0,1.0] [0.9,1.0] dev_patch_db_fact True |
| 234 | + ... |
| 235 | + 0 4 dev_server patch_confidence [0.9,1.0] [0.0,1.0] unpatched_rule False |
| 236 | + |
| 237 | + Inconsistency Message: |
| 238 | + "Inconsistency occurred. Conflicting bounds for patch_confidence(dev_server). |
| 239 | + Update from [0.900, 1.000] to [0.000, 0.200] is not allowed. |
| 240 | + Setting bounds to [0,1] and static=True for this timestep." |
| 241 | + |
| 242 | +For ``web_server`` and ``workstation_1`` the same rule operation is |
| 243 | +consistent:: |
| 244 | + |
| 245 | + 0 4 web_server patch_confidence [0.0,1.0] [0.0,0.2] unpatched_rule True |
| 246 | + 0 4 workstation_1 patch_confidence [0.0,1.0] [0.0,0.2] unpatched_rule True |
| 247 | + |
| 248 | +Why is only ``dev_server`` inconsistent? Because only ``dev_server`` |
| 249 | +had a prior asserted bound for ``patch_confidence`` (the |
| 250 | +``dev_patch_db_fact``). The other two assets had the default |
| 251 | +``[0.0, 1.0]`` bound, which the rule's update fits inside. |
| 252 | + |
| 253 | +After the inconsistency, the asserted fact re-asserts at later |
| 254 | +timesteps, so the final state ends up at ``[0.9, 1.0]``. But the |
| 255 | +trace preserves the record of the conflict that occurred along the way. |
| 256 | + |
| 257 | +**The counterfactual:** |
| 258 | + |
| 259 | +We re-run reasoning with ``dev_patch_db_fact`` removed. |
| 260 | + |
| 261 | +In the counterfactual trace, the same ``unpatched_rule`` grounding |
| 262 | +fires for ``dev_server`` -- the rule body still holds. But now there |
| 263 | +is no prior bound to conflict with:: |
| 264 | + |
| 265 | + 0 4 dev_server patch_confidence [0.0,1.0] [0.0,0.2] unpatched_rule True |
| 266 | + |
| 267 | +Consistent. No inconsistency message. |
| 268 | + |
| 269 | +Diff vs. baseline: |
| 270 | + |
| 271 | +================ ================= ================= ================== |
| 272 | +node predicate baseline counterfactual |
| 273 | +================ ================= ================= ================== |
| 274 | +dev_server patch_confidence [0.9, 1.0] [0.0, 0.2] |
| 275 | +================ ================= ================= ================== |
| 276 | + |
| 277 | +Removing one fact eliminated the baseline inconsistency. This is the |
| 278 | +diagnostic pattern -- when an inconsistency exists and you want to |
| 279 | +know which fact caused it, counterfactually remove each candidate and |
| 280 | +check whether the conflict goes away. |
| 281 | + |
| 282 | +In a real system with many facts and many rules, this becomes a |
| 283 | +systematic technique: remove each fact one at a time, observe which |
| 284 | +removals eliminate the inconsistency, and you have identified the |
| 285 | +load-bearing inputs. |
| 286 | + |
| 287 | +Notice that the *grounding itself* is identical in both runs. The |
| 288 | +``unpatched_rule`` grounding for ``dev_server`` fires in both. What |
| 289 | +changes is the outcome of that grounding's update -- inconsistent in |
| 290 | +the baseline, consistent in the counterfactual. That is a more subtle |
| 291 | +effect than Demos 1 and 2, where the fact change eliminated |
| 292 | +groundings outright. Here the grounding stays; only its consistency |
| 293 | +changes. |
| 294 | + |
| 295 | +Running the Code |
| 296 | +---------------- |
| 297 | + |
| 298 | +:: |
| 299 | + |
| 300 | + python examples/counterfactual_tutorial_ex.py |
| 301 | + |
| 302 | +CSV traces are written to the working directory. The script runs |
| 303 | +all three demos in sequence and prints the diffs above. |
| 304 | + |
| 305 | +Summary |
| 306 | +------- |
| 307 | + |
| 308 | +Two things to take away: |
| 309 | + |
| 310 | +1. **Counterfactuals are re-runs and diffs.** PyReason does not |
| 311 | + provide them as a built-in operator. The pattern is: run, perturb, |
| 312 | + run again, compare. |
| 313 | + |
| 314 | +2. **Perturbations affect groundings.** The unit of rule firing is the |
| 315 | + grounding -- a specific instantiation of a rule's variables. |
| 316 | + Counterfactual changes either eliminate groundings (Demos 1 and 2) |
| 317 | + or change whether their resulting updates are consistent (Demo 3). |
0 commit comments