aGrUM 3.0.0
a C++ library for (probabilistic) graphical models
doorCriteria.cpp
Go to the documentation of this file.
1/****************************************************************************
2 * This file is part of the aGrUM/pyAgrum library. *
3 * *
4 * Copyright (c) 2005-2026 by *
5 * - Pierre-Henri WUILLEMIN(_at_LIP6) *
6 * - Christophe GONZALES(_at_AMU) *
7 * *
8 * The aGrUM/pyAgrum library is free software; you can redistribute it *
9 * and/or modify it under the terms of either : *
10 * *
11 * - the GNU Lesser General Public License as published by *
12 * the Free Software Foundation, either version 3 of the License, *
13 * or (at your option) any later version, *
14 * - the MIT license (MIT), *
15 * - or both in dual license, as here. *
16 * *
17 * (see https://agrum.gitlab.io/articles/dual-licenses-lgplv3mit.html) *
18 * *
19 * This aGrUM/pyAgrum library is distributed in the hope that it will be *
20 * useful, but WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, *
21 * INCLUDING BUT NOT LIMITED TO THE WARRANTIES MERCHANTABILITY or FITNESS *
22 * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE *
23 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER *
24 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, *
25 * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR *
26 * OTHER DEALINGS IN THE SOFTWARE. *
27 * *
28 * See LICENCES for more details. *
29 * *
30 * SPDX-FileCopyrightText: Copyright 2005-2026 *
31 * - Pierre-Henri WUILLEMIN(_at_LIP6) *
32 * - Christophe GONZALES(_at_AMU) *
33 * SPDX-License-Identifier: LGPL-3.0-or-later OR MIT *
34 * *
35 * Contact : info_at_agrum_dot_org *
36 * homepage : http://agrum.gitlab.io *
37 * gitlab : https://gitlab.com/agrumery/agrum *
38 * *
39 ****************************************************************************/
40
41
46
47#include <algorithm>
48#include <ranges>
49#include <vector>
50
53
54#ifdef GUM_NO_INLINE
56#endif // GUM_NO_INLINE
57
58namespace gum {
59
60 namespace {
61 // Convert NodeSet to sorted std::vector<NodeId> for deterministic ops.
62 std::vector< NodeId > _sortedVec(const NodeSet& s) {
63 std::vector< NodeId > v;
64 v.reserve(s.size());
65 for (auto n: s)
66 v.push_back(n);
67 std::ranges::sort(v);
68 return v;
69 }
70
71 // Deterministic lexicographic comparator for NodeSet in O(n).
72 // Key insight: the result is determined by e = min(A △ B), the first position
73 // where sorted(A) and sorted(B) diverge.
74 // - e ∈ A: A[k]=e < B[k] unless B is exhausted (B is a prefix of A -> B < A).
75 // - e ∈ B: B[k]=e < A[k] unless A is exhausted (A is a prefix of B -> A < B).
76 bool _lexLess(const NodeSet& a, const NodeSet& b) {
77 NodeId minDiff = std::numeric_limits< NodeId >::max();
78 bool minInA = false;
79
80 for (auto n: a)
81 if (!b.exists(n) && n < minDiff) {
82 minDiff = n;
83 minInA = true;
84 }
85 for (auto n: b)
86 if (!a.exists(n) && n < minDiff) {
87 minDiff = n;
88 minInA = false;
89 }
90
91 if (minDiff == std::numeric_limits< NodeId >::max()) return false; // a == b
92
93 if (minInA) {
94 // a < b iff b has at least one element > minDiff (otherwise b subset a)
95 for (auto n: b)
96 if (n > minDiff) return true;
97 return false;
98 } else {
99 // a < b iff a has no element > minDiff (otherwise a[k] > b[k] = minDiff)
100 for (auto n: a)
101 if (n > minDiff) return false;
102
103 return true;
104 }
105 }
106 } // namespace
107
108 /* ========================= Backdoor / Frontdoor ========================= */
109
111 NodeId X,
112 NodeId Y,
113 const NodeSet& Z) {
114 // Rule-out illegal contents up front:
115 // - Z must not contain X (conditioning on X is not a valid adjustment set)
116 // - Z must not contain Y (keep parity with enumeration/universe)
117 if (Z.contains(X) || Z.contains(Y)) return false;
118
119 // - Z must not contain any descendant of X
120 NodeSet descX = dag.descendants(X);
121 for (auto dx: descX) {
122 if (Z.contains(dx)) return false;
123 }
124
125 // Backdoor blocking condition in G_{underline X}
126 NodeSet Xs;
127 Xs.insert(X);
128 NodeSet Ys;
129 Ys.insert(Y);
130 return Separation::isBackdoorSeparated(dag, Xs, Ys, Z);
131 }
132
134 NodeId X,
135 NodeId Y,
136 const NodeSet& Z) {
137 if (Z.empty()) return false;
138
139 // (FD-1) Z must intercept all directed X->...->Y paths
140 if (existsUnblockedDirectedPath(dag, X, Y, Z)) return false;
141
142 // (FD-2) Z ∩ backdoor_reach(X) = {}
143 NodeSet br = backdoorReach(dag, X);
144 for (auto z: Z)
145 if (br.contains(z)) return false;
146
147 // (FD-3) check on a d-separation reduced graph with interest = Z ∪ {X,Y}
148 NodeSet Xset;
149 Xset.insert(X);
150 NodeSet Yset;
151 Yset.insert(Y);
152 // interest = X ∪ Y ∪ Z -> pass Xset, Yset, Z as-is
153 DAG reduced = Separation::reduceForDSeparation(dag, Xset, Yset, Z);
154
155 NodeSet condX;
156 condX.insert(X);
157 for (auto z: Z) {
158 NodeSet Zs;
159 Zs.insert(z);
160 // backdoor_path(reduced, z, y, {x}) == !isBackdoorSeparated(reduced, {z}, {y}, {x})
161 if (!Separation::isBackdoorSeparated(reduced, Zs, Yset, condX)) return false;
162 }
163 return true;
164 }
165
167 NodeId X,
168 NodeId Y,
169 const NodeSet& excluded_nodes,
170 std::size_t max_cardinality,
171 bool only_minimal,
172 bool stopAtFirst) {
173 NodeSetVec out;
174
175 // Theoretical variant: if X has no parents, {} is a valid backdoor set.
176 if (dag.parents(X).empty()) {
177 out.emplace_back();
178 return out;
179 }
180
181 // If Y is a parent of X, there is an open backdoor X<-Y that {} cannot block.
182 if (dag.existsArc(Y, X)) return out;
183
184 // Reduce around interest = {X, Y}; evidence = {}
185 NodeSet Xset;
186 Xset.insert(X);
187 NodeSet Yset;
188 Yset.insert(Y);
189 NodeSet Zempty;
190
191 // test {} first
192 if (Separation::isBackdoorSeparated(dag, Xset, Yset, Zempty)) {
193 out.push_back(Zempty);
194 if (only_minimal || stopAtFirst) return out;
195 }
196
197 DAG G = Separation::reduceForDSeparation(dag, Xset, Yset, Zempty);
198
199 // Candidate pool = nodes(G) \ (Desc(X) ∪ {X,Y} ∪ excluded_nodes)
200 NodeSet descX = dag.descendants(X);
201 NodeSet possible;
202 for (auto n: G.nodes()) {
203 if (n == X || n == Y) continue;
204 if (excluded_nodes.contains(n)) continue;
205 if (descX.contains(n)) continue;
206 possible.insert(n);
207 }
208 if (possible.empty()) return out;
209
210 auto cand = _sortedVec(possible);
211 const std::size_t N = cand.size();
212 const std::size_t Kmax = (max_cardinality == 0) ? N : std::min(max_cardinality, N);
213
214 // Only prune supersets in only_minimal mode
215 NodeSetVec chosen;
216 const bool prune_supersets = only_minimal;
217
218 NodeSet Xs;
219 Xs.insert(X);
220 NodeSet Ys;
221 Ys.insert(Y);
222
223 // pyagrum tries k >= 1 (never yields {} except our explicit no-parent case above)
224 for (std::size_t k = 1; k <= Kmax; ++k) {
225 std::vector< bool > pick(N, false);
226 std::fill_n(pick.begin(), k, true);
227 do {
228 NodeSet Z;
229 for (std::size_t i = 0; i < N; ++i)
230 if (pick[i]) Z.insert(cand[i]);
231
232 if (prune_supersets) {
233 bool worth = true;
234 for (const auto& s: chosen) {
235 bool subset = true;
236 for (auto u: s)
237 if (!Z.contains(u)) {
238 subset = false;
239 break;
240 }
241 if (subset) {
242 worth = false;
243 break;
244 }
245 }
246 if (!worth) continue;
247 }
248
249 if (Separation::isBackdoorSeparated(G, Xs, Ys, Z)) {
250 if (only_minimal) {
251 chosen.push_back(Z);
252 out.push_back(Z);
253 } else {
254 out.push_back(Z); // keep non-minimal supersets too (e.g., {U,W})
255 }
256 if (stopAtFirst) return out;
257 }
258
259 } while (std::prev_permutation(pick.begin(), pick.end()));
260 }
261
262 // Deterministic order + dedup
263 std::sort(out.begin(), out.end(), _lexLess);
264 out.erase(std::unique(out.begin(),
265 out.end(),
266 [](const NodeSet& a, const NodeSet& b) {
267 return _sortedVec(a) == _sortedVec(b);
268 }),
269 out.end());
270 return out;
271 }
272
274 DoorCriteria::enumerateBackdoorSets(const DAG& dag, NodeId X, NodeId Y, bool stopAtFirst) {
275 return enumerateBackdoorSets(dag, X, Y, NodeSet{}, 0, true, stopAtFirst);
276 }
277
279 NodeId X,
280 NodeId Y,
281 const NodeSet& excluded_nodes,
282 std::size_t max_cardinality,
283 bool only_minimal,
284 bool stopAtFirst) {
285 NodeSetVec out;
286
287 // pyagrum early exit: if X is a parent of Y, yield nothing
288 for (auto p: dag.parents(Y)) {
289 if (p == X) { return out; }
290 }
291
292 // Candidate pool
293 auto possibleOpt = nodesOnDirectedPaths(dag, X, Y);
294 bool noDiPath = !possibleOpt.has_value();
295
296 NodeSet possible;
297 if (possibleOpt.has_value()) { possible = *possibleOpt; }
298
299 if (noDiPath) {
300 // No directed path: use weakly connected component containing both X and Y (undirected BFS)
301 NodeSet cc;
302 {
303 std::vector< NodeId > st;
304 st.push_back(X);
305 cc.insert(X);
306 while (!st.empty()) {
307 NodeId u = st.back();
308 st.pop_back();
309 for (auto v: dag.parents(u))
310 if (!cc.contains(v)) {
311 cc.insert(v);
312 st.push_back(v);
313 }
314 for (auto v: dag.children(u))
315 if (!cc.contains(v)) {
316 cc.insert(v);
317 st.push_back(v);
318 }
319 }
320 }
321 if (!cc.contains(Y)) { return out; }
322 noDiPath = true;
323 possible = cc;
324 possible.erase(X);
325 possible.erase(Y);
326 } else {
327 possible.erase(X);
328 possible.erase(Y);
329 }
330
331 // Prune by backdoor reach of X and user exclusions
332 NodeSet br = backdoorReach(dag, X);
333 for (auto n: br) {
334 possible.erase(n);
335 }
336 for (auto n: excluded_nodes) {
337 possible.erase(n);
338 }
339
340 // Remove "impossible" z with a backdoor to Y given X, on a reduced graph:
341 // interest = {X, Y} ∪ possible; evidence = {} (parity with pyagrum)
342 NodeSet Xset;
343 Xset.insert(X);
344 NodeSet Yset = possible;
345 Yset.insert(Y);
346 NodeSet Zempty;
347
348 DAG g = Separation::reduceForDSeparation(dag, Xset, Yset, Zempty);
349
350 NodeSet impossible;
351 NodeSet condX;
352 condX.insert(X);
353 for (auto z: possible) {
354 NodeSet Zs;
355 Zs.insert(z);
356 if (!Separation::isBackdoorSeparated(g, Zs, NodeSet{Y}, condX)) { impossible.insert(z); }
357 }
358 for (auto z: impossible) {
359 possible.erase(z);
360 }
361
362 if (possible.empty()) { return out; }
363
364 auto cand = _sortedVec(possible);
365
366 if (noDiPath) {
367 // pyagrum: yield each singleton
368 for (auto n: cand) {
369 NodeSet Z;
370 Z.insert(n);
371 out.push_back(Z);
372 if (stopAtFirst) { return out; }
373 }
374 } else {
375 const std::size_t N = cand.size();
376 const std::size_t Kmax = (max_cardinality == 0) ? N : std::min(max_cardinality, N);
377 for (std::size_t k = 1; k <= Kmax; ++k) {
378 std::vector< bool > pick(N, false);
379 std::fill_n(pick.begin(), k, true);
380 do {
381 NodeSet Z;
382 for (std::size_t i = 0; i < N; ++i) {
383 if (pick[i]) { Z.insert(cand[i]); }
384 }
385 // (FD-1): must block all directed X->Y paths
386 if (!existsUnblockedDirectedPath(dag, X, Y, Z)) {
387 if (!only_minimal || _isMinimalFrontdoorAdjustment(dag, X, Y, Z)) {
388 out.push_back(Z);
389 if (stopAtFirst) { return out; }
390 }
391 }
392 } while (std::prev_permutation(pick.begin(), pick.end()));
393 }
394 }
395
396 std::sort(out.begin(), out.end(), _lexLess);
397 out.erase(std::unique(out.begin(),
398 out.end(),
399 [](const NodeSet& a, const NodeSet& b) {
400 return _sortedVec(a) == _sortedVec(b);
401 }),
402 out.end());
403 return out;
404 }
405
407 DoorCriteria::enumerateFrontdoorSets(const DAG& dag, NodeId X, NodeId Y, bool stopAtFirst) {
408 return enumerateFrontdoorSets(dag, X, Y, NodeSet{}, 0, true, stopAtFirst);
409 }
410
411 /* ============================== Utilities =============================== */
412
414 NodeId X,
415 NodeId Y,
416 const NodeSet& Z) {
417 NodeSet visited;
418 return _existsUnblockedDirectedPath_(dag, X, Y, Z, visited);
419 }
420
422 NodeId X,
423 NodeId Y,
424 const NodeSet& Z,
425 NodeSet& visited) {
426 if (visited.contains(X)) return false;
427 visited.insert(X);
428 if (dag.existsArc(X, Y)) return true;
429 for (auto c: dag.children(X)) {
430 if (!Z.contains(c) && _existsUnblockedDirectedPath_(dag, c, Y, Z, visited)) return true;
431 }
432 return false;
433 }
434
435 std::optional< NodeSet > DoorCriteria::nodesOnDirectedPaths(const DAG& dag, NodeId X, NodeId Y) {
436 const NodeSet f = dag.descendants(X); // forward reach
437 const NodeSet r = dag.ancestors(Y); // reverse reach
438
439 if (!f.contains(Y)) return std::nullopt;
440
441 NodeSet res;
442 // A node is on some X->...->Y path iff reachable from X and can reach Y.
443 for (auto n: f)
444 if (r.contains(n)) res.insert(n);
445
446 // To match pyagrum version:
447 // - do not include X
448 // - do not include Y
449 res.erase(X);
450 res.erase(Y);
451
452 return res;
453 }
454
455 namespace {
456 // pyagrum.backdoor_reach inner function:
457 // inner_br(bn, x, pht, reach0, reach1)
458 // - always explore children into reach1
459 // - explore parents into reach0 only if pht == false
460 void _inner_br(const DAG& dag, NodeId x, bool pht, NodeSet& reach0, NodeSet& reach1) {
461 // children phase
462 for (auto c: dag.children(x)) {
463 if (!reach0.contains(c) && !reach1.contains(c)) {
464 reach1.insert(c);
465 _inner_br(dag, c, true, reach0, reach1); // after child step, forbid next parent step
466 }
467 }
468 // parents phase (only if we did not just take a parent step)
469 if (!pht) {
470 for (auto p: dag.parents(x)) {
471 if (!reach0.contains(p)) {
472 reach0.insert(p);
473 _inner_br(dag, p, false, reach0, reach1);
474 }
475 }
476 }
477 }
478 } // namespace
479
481 NodeSet reach0; // via a parent step
482 NodeSet reach1; // via a child step
483
484 // Seed with 'a' to avoid parent->a->child zig-zag artifacts
485 reach0.insert(a);
486 reach1.insert(a);
487
488 // parents of X are themselves in backdoor reach (X <- parent)
489 for (auto pa: dag.parents(a))
490 reach0.insert(pa);
491
492 // Start the alternating traversal from each parent
493 for (auto pa: dag.parents(a)) {
494 _inner_br(dag, pa, /*pht=*/false, reach0, reach1);
495 }
496
497 NodeSet s = reach0;
498 for (auto n: reach1)
499 s.insert(n);
500 s.erase(a);
501 return s;
502 }
503
504 bool DoorCriteria::hasBackdoorPath(const DAG& dag, NodeId X, NodeId Y, const NodeSet& Z) {
505 NodeSet Xs;
506 Xs.insert(X);
507 NodeSet Ys;
508 Ys.insert(Y);
509 return !Separation::isBackdoorSeparated(dag, Xs, Ys, Z);
510 }
511
512 /* ============================= Minimality =============================== */
513
515 NodeId X,
516 NodeId Y,
517 const NodeSet& Z) {
518 for (auto z: Z) {
519 NodeSet sub = Z;
520 sub.erase(z);
521 if (satisfiesBackdoorCriterion(dag, X, Y, sub)) return false;
522 }
523 return true;
524 }
525
527 NodeId X,
528 NodeId Y,
529 const NodeSet& Z) {
530 if (Z.empty()) return false; // frontdoor sets must be non-empty
531 for (auto z: Z) {
532 NodeSet sub = Z;
533 sub.erase(z);
534 if (satisfiesFrontdoorCriterion(dag, X, Y, sub)) return false;
535 }
536 return true;
537 }
538
539} // namespace gum
d-separation utilities
bool existsArc(const Arc &arc) const
indicates whether a given arc exists
const NodeSet & parents(NodeId id) const
returns the set of nodes with arc ingoing to a given node
NodeSet children(const NodeSet &ids) const
returns the set of nodes which consists in the node and its parents returns the set of children of a ...
Base class for dag.
Definition DAG.h:121
NodeSet ancestors(NodeId id) const
returns the set of all ancestors of id (nodes from which id is reachable)
NodeSet descendants(NodeId id) const
returns the set of all descendants of id (nodes reachable from id)
static bool _isMinimalBackdoorAdjustment(const DAG &dag, NodeId X, NodeId Y, const NodeSet &Z)
Check whether Z is a minimal backdoor adjustment set.
static bool _existsUnblockedDirectedPath_(const DAG &dag, NodeId X, NodeId Y, const NodeSet &Z, NodeSet &visited)
static NodeSetVec enumerateBackdoorSets(const DAG &dag, NodeId X, NodeId Y, const NodeSet &excluded_nodes=NodeSet(), std::size_t max_cardinality=0, bool only_minimal=true, bool stopAtFirst=false)
Enumerate valid backdoor adjustment sets.
static bool hasBackdoorPath(const DAG &dag, NodeId X, NodeId Y, const NodeSet &Z)
Test whether there exists an open backdoor path from X to Y given Z.
static NodeSetVec enumerateFrontdoorSets(const DAG &dag, NodeId X, NodeId Y, const NodeSet &excluded_nodes=NodeSet(), std::size_t max_cardinality=0, bool only_minimal=true, bool stopAtFirst=false)
Enumerate valid frontdoor adjustment sets.
static bool existsUnblockedDirectedPath(const DAG &dag, NodeId X, NodeId Y, const NodeSet &Z)
Check whether there exists a directed path X->..->Y unblocked by Z.
static std::optional< NodeSet > nodesOnDirectedPaths(const DAG &dag, NodeId X, NodeId Y)
Compute nodes lying on some directed path from X to Y.
static bool satisfiesFrontdoorCriterion(const DAG &dag, NodeId X, NodeId Y, const NodeSet &Z)
Check if Z satisfies the frontdoor criterion relative to (X, Y).
static bool satisfiesBackdoorCriterion(const DAG &dag, NodeId X, NodeId Y, const NodeSet &Z)
Check if Z satisfies the backdoor criterion relative to (X, Y).
std::vector< NodeSet > NodeSetVec
Convenience type: list of candidate adjustment sets.
static NodeSet backdoorReach(const DAG &dag, NodeId X)
Compute the "backdoor reach" of a node.
static bool _isMinimalFrontdoorAdjustment(const DAG &dag, NodeId X, NodeId Y, const NodeSet &Z)
Check whether Z is a minimal frontdoor adjustment set.
const NodeGraphPart & nodes() const
return *this as a NodeGraphPart
static DAG reduceForDSeparation(const DAG &dag, const NodeSet &X, const NodeSet &Y, const NodeSet &Z)
Barren-node pruning relative to the interest set (X \cup Y \cup Z).
static bool isBackdoorSeparated(const DAG &dag, const NodeSet &X, const NodeSet &Y, const NodeSet &Z)
Backdoor-style restriction: only paths whose first edge points into X.
bool contains(const Key &k) const
Indicates whether a given elements belong to the set.
Definition set_tpl.h:468
bool empty() const noexcept
Indicates whether the set is the empty set.
Definition set_tpl.h:613
void insert(const Key &k)
Inserts a new element into the set.
Definition set_tpl.h:510
void erase(const Key &k)
Erases an element from the set.
Definition set_tpl.h:553
Door criteria (backdoor/frontdoor) utilities for a DAG.
Size NodeId
Type for node ids.
Set< NodeId > NodeSet
Some typdefs and define for shortcuts ...
gum is the global namespace for all aGrUM entities
Definition agrum.h:46