aGrUM 3.0.0
a C++ library for (probabilistic) graphical models
FCI.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
42#include <algorithm>
43#include <numeric>
44#include <queue>
45#include <set>
46#include <vector>
47
49
50namespace gum::learning {
51
52 // ##########################################################################
53 // Constructors / Destructors
54 // ##########################################################################
55
56 FCI::FCI() = default;
57 FCI::~FCI() = default;
58
59 FCI::FCI(FCI&& from) noexcept :
60 CIBasedLearning(std::move(from)), maxPathLength_(from.maxPathLength_) {}
61
62 FCI& FCI::operator=(const FCI& from) {
63 if (this != &from) {
66 }
67 return *this;
68 }
69
70 FCI& FCI::operator=(FCI&& from) noexcept {
71 if (this != &from) {
72 CIBasedLearning::operator=(std::move(from));
73 maxPathLength_ = from.maxPathLength_;
74 }
75 return *this;
76 }
77
78 // ##########################################################################
79 // Parameterisation
80 // ##########################################################################
81
82 void FCI::setMaxPathLength(Size maxLen) { maxPathLength_ = maxLen; }
83
85
86 std::vector< NodeId > FCI::possibleDSep(const PAG& pag, NodeId x, NodeId y) const {
87 return computePossibleDSep_(pag, x, y);
88 }
89
90 // ##########################################################################
91 // Conflict hook override — FCI leaves circle marks unchanged
92 // ##########################################################################
93
94 void FCI::resolveOrientConflict_(NodeId /*src*/, NodeId /*dst*/) {
95 // no-op: circle marks remain; only final ↔ edges (Phase 6) become _latentCouples_
96 }
97
98 // ##########################################################################
99 // Phase 2 / Phase 4 — PAG collider orientation
100 // ##########################################################################
101
102 void FCI::orientCollidersOnPAG_(PAG& pag, const MixedGraph& topology) {
103 const auto triples = unshieldedTriples_(topology);
104
105 const auto isCollider = [&](NodeId X, NodeId Y, NodeId Z) -> bool {
106 if (!sepSet_.exists({X, Y})) { return false; }
107 const SepSetEntry_& entry = sepSet_[{X, Y}];
108 return std::ranges::find(entry.cond, Z) == entry.cond.end();
109 };
110
112 for (const ThreePoints& tp: triples) {
113 const NodeId X = std::get< 0 >(tp);
114 const NodeId Y = std::get< 1 >(tp);
115 const NodeId Z = std::get< 2 >(tp);
116 if (!isCollider(X, Y, Z)) { continue; }
117 if (pag.existsEdge(X, Z) && !isForbiddenArc_(X, Z)) {
119 }
120 if (pag.existsEdge(Y, Z) && !isForbiddenArc_(Y, Z)) {
122 }
123 }
124 } else {
125 struct Candidate {
126 NodeId X, Y, Z;
127 double pval;
128 };
129
130 std::vector< Candidate > candidates;
131 candidates.reserve(triples.size());
132
133 for (const ThreePoints& tp: triples) {
134 const NodeId X = std::get< 0 >(tp);
135 const NodeId Y = std::get< 1 >(tp);
136 const NodeId Z = std::get< 2 >(tp);
137 if (!isCollider(X, Y, Z)) { continue; }
138 candidates.push_back({.X = X, .Y = Y, .Z = Z, .pval = sepSet_[{X, Y}].pval});
139 }
140
141 std::ranges::sort(candidates,
142 [](const Candidate& a, const Candidate& b) { return a.pval > b.pval; });
143
144 for (const auto& [X, Y, Z, pval]: candidates) {
145 if (pag.existsEdge(X, Z) && !isForbiddenArc_(X, Z)) {
147 }
148 if (pag.existsEdge(Y, Z) && !isForbiddenArc_(Y, Z)) {
150 }
151 }
152 }
153 }
154
155 // ##########################################################################
156 // Path helpers (anonymous namespace) — placed before possibleDSep helpers
157 // so that computePossibleDSep_ can call existsSemiDirectedPath_.
158 // ##########################################################################
159
160 namespace {
161 // Semi-directed path BFS: traverse edge X→W when no arrowhead at X from W.
162 bool existsSemiDirectedPath_(const PAG& pag, NodeId from, NodeId to) {
163 std::set< NodeId > visited{from};
164 std::queue< NodeId > q;
165 q.push(from);
166 while (!q.empty()) {
167 const NodeId cur = q.front();
168 q.pop();
169 if (cur == to) { return true; }
170 for (const NodeId w: pag.neighbours(cur)) {
171 if (visited.count(w) > 0) { continue; }
172 if (!pag.isArrowhead(w, cur)) { // endpoint at cur from w ≠ arrowhead
173 visited.insert(w);
174 q.push(w);
175 }
176 }
177 }
178 return false;
179 }
180
181 // DFS backbone for existsUncoveredPdPath_.
182 bool uncoveredPdDFS_(const PAG& pag,
183 NodeId to,
184 std::vector< NodeId >& path,
185 std::set< NodeId >& visited) {
186 const NodeId cur = path.back();
187 if (cur == to) {
188 for (std::size_t i = 0; i + 2 < path.size(); ++i) {
189 if (pag.existsEdge(path[i], path[i + 2])) { return false; }
190 }
191 return true;
192 }
193 for (const NodeId w: pag.neighbours(cur)) {
194 if (visited.count(w) > 0) { continue; }
195 if (pag.isArrowhead(w, cur)) { continue; } // endpoint at cur from w is arrowhead
196 if (pag.isTail(cur, w)) { continue; } // endpoint at w from cur is tail
197 visited.insert(w);
198 path.push_back(w);
199 if (uncoveredPdDFS_(pag, to, path, visited)) { return true; }
200 path.pop_back();
201 visited.erase(w);
202 }
203 return false;
204 }
205
206 // Exists uncovered possibly-directed path from→next→…→to.
207 bool existsUncoveredPdPath_(const PAG& pag, NodeId from, NodeId next, NodeId to) {
208 std::set< NodeId > visited{from, next};
209 std::vector< NodeId > path{from, next};
210 return uncoveredPdDFS_(pag, to, path, visited);
211 }
212
213 // DFS collecting all uncovered Circle-Circle paths from path.back() to `to`.
214 // excludes: nodes not allowed on path (typically {A, B}).
215 // Returns all paths via `result`; each path includes the start node.
216 void findUncoveredCirclePaths_(const PAG& pag,
217 NodeId to,
218 std::vector< NodeId >& path,
219 std::set< NodeId >& visited,
220 const std::vector< NodeId >& excludes,
221 std::vector< std::vector< NodeId > >& result) {
222 const NodeId cur = path.back();
223 if (cur == to) {
224 bool uncovered = true;
225 for (std::size_t i = 0; i + 2 < path.size() && uncovered; ++i) {
226 if (pag.existsEdge(path[i], path[i + 2])) { uncovered = false; }
227 }
228 if (uncovered) { result.push_back(path); }
229 return;
230 }
231 for (const NodeId next: pag.neighbours(cur)) {
232 if (visited.count(next) > 0) { continue; }
233 if (std::ranges::find(excludes, next) != excludes.end()) { continue; }
234 if (!pag.isCircle(cur, next) || !pag.isCircle(next, cur)) { continue; }
235 visited.insert(next);
236 path.push_back(next);
237 findUncoveredCirclePaths_(pag, to, path, visited, excludes, result);
238 path.pop_back();
239 visited.erase(next);
240 }
241 }
242 } // anonymous namespace
243
244 // ##########################################################################
245 // Phase 3 — possibleDSep helpers
246 // ##########################################################################
247
248 std::vector< NodeId > FCI::computePossibleDSep_(const PAG& pag, NodeId x, NodeId y) const {
249 std::set< NodeId > result;
250 std::queue< std::pair< NodeId, NodeId > > bfsQueue;
251 std::set< std::pair< NodeId, NodeId > > visited;
252
253 for (const NodeId b: pag.neighbours(x)) {
254 if (b == y) { continue; }
255 if (visited.insert({x, b}).second) {
256 bfsQueue.emplace(x, b);
257 result.insert(b);
258 }
259 }
260
261 while (!bfsQueue.empty()) {
262 const auto [a, b] = bfsQueue.front();
263 bfsQueue.pop();
264
265 for (const NodeId c: pag.neighbours(b)) {
266 if (c == x || c == y || c == a) { continue; }
267 // expand if B is a definite collider on (A, B, C) or A and C are adjacent
268 // (Zhang 2008 / Colombo et al. criterion)
269 if (pag.isDefCollider(a, b, c) || pag.existsEdge(a, c)) {
270 if (visited.insert({b, c}).second) {
271 bfsQueue.emplace(b, c);
272 // FIX #3: only add c to PossibleDSep if it has a semi-directed path
273 // back toward x or b (Zhang 2008 §3.2 filter).
274 if (existsSemiDirectedPath_(pag, c, x) || existsSemiDirectedPath_(pag, c, b)) {
275 result.insert(c);
276 }
277 }
278 }
279 }
280 }
281
282 result.erase(x);
283 result.erase(y);
284 return {result.begin(), result.end()};
285 }
286
288 // snapshot edges to avoid invalidation during removal
289 const std::vector< Edge > edgeSnap(pag.edges().begin(), pag.edges().end());
290
291 const auto tryRemove = [&](NodeId X, NodeId Y, const std::vector< NodeId >& dsep) {
292 const Size maxK = (maxCondSetSize_ == Size(-1))
293 ? static_cast< Size >(dsep.size())
294 : std::min(maxCondSetSize_, static_cast< Size >(dsep.size()));
295
296 // FIX #4: start at k=2; sizes 0 and 1 were already exhaustively tested
297 // in Phase 1 (FAS skeleton discovery).
298 for (Size k = 2; k <= maxK; ++k) {
299 if (dsep.size() < k) { break; }
300
301 std::vector< std::size_t > idx(k);
302 std::iota(idx.begin(), idx.end(), std::size_t(0));
303
304 for (;;) {
305 std::vector< NodeId > cond(k);
306 for (Size i = 0; i < k; ++i) {
307 cond[i] = dsep[idx[i]];
308 }
309
310 const double pval = test_->statistics(X, Y, cond).second;
311 if (pval > alpha_) {
312 pag.eraseEdge(Edge(X, Y));
313 sepSet_.set({X, Y}, SepSetEntry_{.cond = cond, .pval = pval});
314 sepSet_.set({Y, X}, SepSetEntry_{.cond = cond, .pval = pval});
315 return true;
316 }
317
318 if (k == 0) { break; }
319 int i = static_cast< int >(k) - 1;
320 while (i >= 0
321 && idx[static_cast< std::size_t >(i)]
322 == dsep.size() - k + static_cast< std::size_t >(i)) {
323 --i;
324 }
325 if (i < 0) { break; }
326 ++idx[static_cast< std::size_t >(i)];
327 for (int j = i + 1; j < static_cast< int >(k); ++j) {
328 idx[static_cast< std::size_t >(j)] = idx[static_cast< std::size_t >(j - 1)] + 1;
329 }
330 }
331 }
332 return false;
333 };
334
335 for (const Edge& edge: edgeSnap) {
336 if (!pag.existsEdge(edge)) { continue; }
337 const NodeId X = edge.first();
338 const NodeId Y = edge.second();
339
340 const auto dsepXY = computePossibleDSep_(pag, X, Y);
341 if (tryRemove(X, Y, dsepXY)) { continue; }
342
343 if (!pag.existsEdge(edge)) { continue; }
344 const auto dsepYX = computePossibleDSep_(pag, Y, X);
345 tryRemove(X, Y, dsepYX);
346 }
347 }
348
349 // ##########################################################################
350 // Phase 5 — orientation rules
351 // ##########################################################################
352
353 // R1: Away from Collider. A *→ B o-* C, A not adj C → B → C
354 bool FCI::ruleR1_(PAG& pag) const {
355 bool changed = false;
356 for (const NodeId b: pag.nodes()) {
357 for (const NodeId a: pag.neighbours(b)) {
358 if (!pag.isArrowhead(a, b)) { continue; }
359 for (const NodeId c: pag.neighbours(b)) {
360 if (c == a) { continue; }
361 if (!pag.isCircle(c, b)) { continue; }
362 if (pag.existsEdge(a, c)) { continue; }
363 if (isForbiddenArc_(b, c)) { continue; }
364 // FIX #1: do not overwrite an existing Tail at C (from B) with Arrowhead.
365 // marks_[Arc(B,C)] == Tail means B *— C is already established;
366 // upgrading it to B *→ C would contradict that orientation.
367 if (pag.isTail(b, c)) { continue; }
368 pag.setMarkAt(c, b, EdgeMark::Tail);
370 changed = true;
371 }
372 }
373 }
374 return changed;
375 }
376
377 // R2: Away from Cycle. A *→ B *→ C, A *-o C, A→B or B→C → A *→ C
378 bool FCI::ruleR2_(PAG& pag) const {
379 bool changed = false;
380 for (const NodeId b: pag.nodes()) {
381 for (const NodeId a: pag.neighbours(b)) {
382 for (const NodeId c: pag.neighbours(b)) {
383 if (a == c) { continue; }
384 if (!pag.existsEdge(a, c)) { continue; }
385 if (!pag.isCircle(a, c)) { continue; } // circle at c from a
386 if (!pag.isArrowhead(a, b)) { continue; } // a *→ b
387 if (!pag.isArrowhead(b, c)) { continue; } // b *→ c
388 if (!pag.isTail(b, a) && !pag.isTail(c, b)) { continue; }
389 if (isForbiddenArc_(a, c)) { continue; }
390 // FIX #1 (defensive): isCircle(a,c) already ensures the mark at C
391 // from A is Circle, so isTail cannot be true here. Added for symmetry
392 // with R1 and to make the guard explicit.
393 if (pag.isTail(a, c)) { continue; }
395 changed = true;
396 }
397 }
398 }
399 return changed;
400 }
401
402 // R3: Double Triangle.
403 // A *→ B ←* C (A,C non-adj), D o-* B, A adj D, C adj D, D non-collider(A,D,C),
404 // circle at D from A and C → D *→ B
405 bool FCI::ruleR3_(PAG& pag) const {
406 bool changed = false;
407 for (const NodeId b: pag.nodes()) {
408 std::vector< NodeId > into_arrows, into_circles;
409 for (const NodeId n: pag.neighbours(b)) {
410 if (pag.isArrowhead(n, b)) { into_arrows.push_back(n); }
411 if (pag.isCircle(n, b)) { into_circles.push_back(n); }
412 }
413 if (into_arrows.size() < 2) { continue; }
414
415 for (const NodeId d: into_circles) {
416 for (std::size_t i = 0; i < into_arrows.size(); ++i) {
417 for (std::size_t j = i + 1; j < into_arrows.size(); ++j) {
418 const NodeId a = into_arrows[i];
419 const NodeId c = into_arrows[j];
420 if (pag.existsEdge(a, c)) { continue; } // A, C not adjacent
421 if (!pag.existsEdge(a, d)) { continue; }
422 if (!pag.existsEdge(c, d)) { continue; }
423 if (!pag.isCircle(a, d)) { continue; } // circle at d from a
424 if (!pag.isCircle(c, d)) { continue; } // circle at d from c
425 // D is non-collider on A-D-C: D ∈ sepSet(A,C)
426 bool nonCollider = false;
427 for (const auto& key: {std::make_pair(a, c), std::make_pair(c, a)}) {
428 if (sepSet_.exists(key)) {
429 const auto& cond = sepSet_[key].cond;
430 if (std::ranges::find(cond, d) != cond.end()) {
431 nonCollider = true;
432 break;
433 }
434 }
435 }
436 if (!nonCollider) { continue; }
438 changed = true;
439 }
440 }
441 }
442 }
443 return changed;
444 }
445
446 // R5: Uncovered Circle Path.
447 // A o—o B, uncovered circle path A o—o C ... D o—o B (B not adj C, A not adj D)
448 // → orient A—B and all path edges as Tail-Tail
449 bool FCI::ruleR5_(PAG& pag) const {
450 bool changed = false;
451 for (const NodeId b: pag.nodes()) {
452 for (const NodeId a: pag.neighbours(b)) {
453 if (!pag.isCircle(a, b) || !pag.isCircle(b, a)) { continue; }
454 for (const NodeId c: pag.neighbours(a)) {
455 if (c == b) { continue; }
456 if (pag.existsEdge(b, c)) { continue; } // B not adj C
457 if (!pag.isCircle(a, c) || !pag.isCircle(c, a)) { continue; }
458 for (const NodeId d: pag.neighbours(b)) {
459 if (d == a || d == c) { continue; }
460 if (pag.existsEdge(a, d)) { continue; } // A not adj D
461 if (!pag.isCircle(b, d) || !pag.isCircle(d, b)) { continue; }
462 const std::vector< NodeId > excludes{a, b};
463 std::vector< std::vector< NodeId > > paths;
464 std::vector< NodeId > path{c};
465 std::set< NodeId > visited{c};
466 findUncoveredCirclePaths_(pag, d, path, visited, excludes, paths);
467 for (const auto& p: paths) {
468 changed = true;
469 pag.setMarkAt(b, a, EdgeMark::Tail);
470 pag.setMarkAt(a, b, EdgeMark::Tail);
471 pag.setMarkAt(c, a, EdgeMark::Tail);
472 pag.setMarkAt(a, c, EdgeMark::Tail);
473 pag.setMarkAt(b, p.back(), EdgeMark::Tail);
474 pag.setMarkAt(p.back(), b, EdgeMark::Tail);
475 for (std::size_t k = 0; k + 1 < p.size(); ++k) {
476 pag.setMarkAt(p[k + 1], p[k], EdgeMark::Tail);
477 pag.setMarkAt(p[k], p[k + 1], EdgeMark::Tail);
478 }
479 }
480 }
481 }
482 }
483 }
484 return changed;
485 }
486
487 // R6: Tail Propagation from undirected edge.
488 // A—B (Tail-Tail) exists, C o-* B → Tail at B from C
489 bool FCI::ruleR6_(PAG& pag) const {
490 bool changed = false;
491 for (const NodeId b: pag.nodes()) {
492 bool has_undirected = false;
493 for (const NodeId a: pag.neighbours(b)) {
494 if (pag.isTail(a, b) && pag.isTail(b, a)) {
495 has_undirected = true;
496 break;
497 }
498 }
499 if (!has_undirected) { continue; }
500 for (const NodeId c: pag.neighbours(b)) {
501 if (pag.isCircle(c, b)) {
502 pag.setMarkAt(c, b, EdgeMark::Tail);
503 changed = true;
504 }
505 }
506 }
507 return changed;
508 }
509
510 // R7: Tail Propagation from A-oB.
511 // A -o B (circle at B from A, tail at A from B), C o-* B (C not adj A) → Tail at B from C
512 bool FCI::ruleR7_(PAG& pag) const {
513 bool changed = false;
514 for (const NodeId b: pag.nodes()) {
515 std::vector< NodeId > a_list;
516 for (const NodeId a: pag.neighbours(b)) {
517 if (pag.isCircle(a, b) && pag.isTail(b, a)) { a_list.push_back(a); }
518 }
519 if (a_list.empty()) { continue; }
520 for (const NodeId c: pag.neighbours(b)) {
521 if (!pag.isCircle(c, b)) { continue; }
522 for (const NodeId a: a_list) {
523 if (a == c) { continue; }
524 if (!pag.existsEdge(a, c)) {
525 pag.setMarkAt(c, b, EdgeMark::Tail);
526 changed = true;
527 break;
528 }
529 }
530 }
531 }
532 return changed;
533 }
534
535 // ##########################################################################
536 // R4 — Discriminating Path
537 // ##########################################################################
538
540 NodeId d,
541 NodeId a,
542 NodeId b,
543 NodeId c,
544 const HashTable< NodeId, NodeId >& previous) const {
545 // Reconstruct conditioning set: follow previous from d toward b
546 std::vector< NodeId > path;
547 NodeId cur = d;
548 while (previous.exists(cur)) {
549 cur = previous[cur];
550 path.push_back(cur);
551 }
552 // path = [previous[d], ..., b]
553
554 const double pval1 = test_->statistics(d, c, path).second;
555 const bool ind1 = pval1 > alpha_;
556
557 std::vector< NodeId > path_no_b;
558 for (const NodeId n: path) {
559 if (n != b) { path_no_b.push_back(n); }
560 }
561 const double pval2 = test_->statistics(d, c, path_no_b).second;
562 const bool ind2 = pval2 > alpha_;
563
564 bool use_b_in_sep;
565 if (!ind1 && !ind2) {
566 // fall back to existing sepSet
567 const bool has_dc = sepSet_.exists({d, c});
568 const bool has_cd = sepSet_.exists({c, d});
569 if (!has_dc && !has_cd) { return false; }
570 const auto& cond = has_dc ? sepSet_[{d, c}].cond : sepSet_[{c, d}].cond;
571 use_b_in_sep = (std::ranges::find(cond, b) != cond.end());
572 } else {
573 use_b_in_sep = ind1;
574 }
575
576 if (use_b_in_sep) {
577 pag.setMarkAt(c, b, EdgeMark::Tail); // B→C: tail at B from C
578 return true;
579 }
580 bool any = false;
581 if (!isForbiddenArc_(a, b)) {
583 any = true;
584 }
585 if (!isForbiddenArc_(c, b)) {
587 any = true;
588 }
589 return any;
590 }
591
592 // R4: Discriminating path rule.
593 // Outer: for each B, A where B*→A, C where C o-* B (circle at B from C):
594 // if A→C and B*→C: search discriminating path D…A,B,C
595 bool FCI::ruleR4_(PAG& pag) const {
596 bool changed = false;
597
598 for (const NodeId b: pag.nodes()) {
599 // possA: nodes A where B *→ A
600 std::vector< NodeId > poss_a;
601 for (const NodeId a: pag.neighbours(b)) {
602 if (pag.isArrowhead(b, a)) { poss_a.push_back(a); }
603 }
604 // possC: nodes C where circle at B from C, AND B *→ C
605 std::vector< NodeId > poss_c;
606 for (const NodeId c: pag.neighbours(b)) {
607 if (pag.isCircle(c, b) && pag.isArrowhead(b, c)) { poss_c.push_back(c); }
608 }
609
610 for (const NodeId a: poss_a) {
611 for (const NodeId c: poss_c) {
612 if (a == c) { continue; }
613 if (!pag.existsEdge(a, c)) { continue; }
614 if (!pag.isArrowhead(a, c) || !pag.isTail(c, a)) { continue; } // A→C (parent)
615
616 // BFS backward from A to find discriminating path D…A,B,C
618 previous.insert(a, b);
619
620 // parents of C: nodes with directed edge into C
621 std::set< NodeId > c_parents;
622 for (const NodeId n: pag.neighbours(c)) {
623 if (pag.isArrowhead(n, c) && pag.isTail(c, n)) { c_parents.insert(n); }
624 }
625
626 std::queue< NodeId > q;
627 std::set< NodeId > visited{a, b};
628 q.push(a);
629
630 // FIX #2: track BFS depth to respect maxPathLength_.
631 // cur_lvl_size counts nodes remaining at the current BFS level;
632 // nxt_lvl_size accumulates nodes pushed for the next level.
633 // distance is incremented each time we exhaust the current level.
634 int distance = 0;
635 Size cur_lvl_size = 1; // node_a is the sole node at level 0
636 Size nxt_lvl_size = 0;
637
638 bool found = false;
639 while (!q.empty() && !found) {
640 const NodeId t = q.front();
641 q.pop();
642
643 const NodeId prev_t = previous[t];
644
645 // nodes D with arrowhead INTO t
646 for (const NodeId d: pag.neighbours(t)) {
647 if (visited.count(d) > 0) { continue; }
648 if (!pag.isArrowhead(d, t)) { continue; }
649 // t must be def collider on (d, t, prev_t)
650 if (!pag.isArrowhead(prev_t, t)) { continue; }
651
652 previous.set(d, t);
653
654 if (!pag.existsEdge(d, c) && d != c) {
655 // found discriminating path
656 if (doDdpOrientation_(pag, d, a, b, c, previous)) {
657 changed = true;
658 found = true;
659 break;
660 }
661 }
662 if (c_parents.count(d) > 0) {
663 q.push(d);
664 visited.insert(d);
665 ++nxt_lvl_size;
666 }
667 }
668
669 if (!found) {
670 --cur_lvl_size;
671 if (cur_lvl_size == 0) {
672 ++distance;
673 if (maxPathLength_ != Size(-1) && static_cast< Size >(distance) > maxPathLength_) {
674 break;
675 }
676 cur_lvl_size = nxt_lvl_size;
677 nxt_lvl_size = 0;
678 }
679 }
680 }
681 }
682 }
683 }
684 return changed;
685 }
686
687 // ##########################################################################
688 // R8–R10 — Ancestor rules
689 // ##########################################################################
690
691 // R8: A→B*→C and A*→C (circle at A from C) → A→C (tail at A from C)
692 // or: A-oB*→C and A*→C (circle at A from C) → A→C
693 bool FCI::ruleR8_(PAG& pag) const {
694 bool changed = false;
695 for (const NodeId b: pag.nodes()) {
696 for (const NodeId a: pag.neighbours(b)) {
697 for (const NodeId c: pag.neighbours(b)) {
698 if (a == c) { continue; }
699 if (!pag.existsEdge(a, c)) { continue; }
700 if (!pag.isArrowhead(a, c)) { continue; } // A *→ C
701 if (!pag.isCircle(c, a)) { continue; } // circle at A from C
702 if (!pag.isArrowhead(b, c)) { continue; } // B *→ C
703 if (!pag.isTail(c, b)) { continue; } // Tail at B from C (B→C directed)
704 const bool case1 = pag.isArrowhead(a, b) && pag.isTail(b, a); // A→B
705 const bool case2 = pag.isCircle(a, b) && pag.isTail(b, a); // A-oB
706 if (!case1 && !case2) { continue; }
707 // FIX #1 (defensive): isCircle(c,a) already ensures this is not Arrowhead,
708 // but guard explicitly for clarity.
709 if (pag.isArrowhead(c, a)) { continue; }
710 pag.setMarkAt(c, a, EdgeMark::Tail); // A→C: tail at A from C
711 changed = true;
712 }
713 }
714 }
715 return changed;
716 }
717
718 // R9: A o→ C, uncovered pd-path A→B→…→C (B not adj C) → A→C
719 bool FCI::ruleR9_(PAG& pag) const {
720 bool changed = false;
721 for (const NodeId c: pag.nodes()) {
722 for (const NodeId a: pag.neighbours(c)) {
723 if (!pag.isArrowhead(a, c)) { continue; } // A *→ C
724 if (!pag.isCircle(c, a)) { continue; } // circle at A from C (A o→ C)
725 bool oriented = false;
726 for (const NodeId b: pag.neighbours(a)) {
727 if (b == c) { continue; }
728 if (pag.existsEdge(b, c)) { continue; } // B not adjacent C
729 if (pag.isArrowhead(b, a)) { continue; } // B must be possible child of A
730 if (existsUncoveredPdPath_(pag, a, b, c)) {
731 pag.setMarkAt(c, a, EdgeMark::Tail);
732 changed = true;
733 oriented = true;
734 break;
735 }
736 }
737 if (oriented) { continue; }
738 }
739 }
740 return changed;
741 }
742
743 // R10: A o→ C, B→C, D→C, two paths from A's possible children to B,D → A→C
744 bool FCI::ruleR10_(PAG& pag) const {
745 bool changed = false;
746 for (const NodeId c: pag.nodes()) {
747 // all nodes with arrowhead into C
748 std::vector< NodeId > into_c;
749 for (const NodeId n: pag.neighbours(c)) {
750 if (pag.isArrowhead(n, c)) { into_c.push_back(n); }
751 }
752 if (into_c.size() < 2) { continue; }
753
754 for (const NodeId a: into_c) {
755 if (!pag.isCircle(c, a)) { continue; } // A o→ C
756 // possible children of A (not C)
757 std::vector< NodeId > a_children;
758 for (const NodeId x: pag.neighbours(a)) {
759 if (x == c) { continue; }
760 if (!pag.isArrowhead(x, a)) { a_children.push_back(x); }
761 }
762 if (a_children.size() < 2) { continue; }
763
764 bool oriented = false;
765 for (std::size_t i = 0; i < into_c.size() && !oriented; ++i) {
766 for (std::size_t j = i + 1; j < into_c.size() && !oriented; ++j) {
767 const NodeId bd = into_c[i];
768 const NodeId dd = into_c[j];
769 if (!pag.isTail(c, bd)) { continue; } // B→C
770 if (!pag.isTail(c, dd)) { continue; } // D→C
771 for (std::size_t m = 0; m < a_children.size() && !oriented; ++m) {
772 for (std::size_t n2 = m + 1; n2 < a_children.size() && !oriented; ++n2) {
773 const NodeId ch1 = a_children[m];
774 const NodeId ch2 = a_children[n2];
775 if (pag.existsEdge(ch1, ch2)) { continue; }
776 // FIX #6: check both (ch1→bd, ch2→dd) and (ch1→dd, ch2→bd).
777 // Zhang 2008 R10 requires one path to B and one to D from two
778 // non-adjacent children; either assignment of children to targets
779 // is valid.
780 const bool direct = existsSemiDirectedPath_(pag, ch1, bd)
781 && existsSemiDirectedPath_(pag, ch2, dd);
782 const bool crossed = existsSemiDirectedPath_(pag, ch1, dd)
783 && existsSemiDirectedPath_(pag, ch2, bd);
784 if (!direct && !crossed) { continue; }
785 pag.setMarkAt(c, a, EdgeMark::Tail);
786 changed = true;
787 oriented = true;
788 }
789 }
790 }
791 }
792 }
793 }
794 return changed;
795 }
796
798 bool changed = true;
799 while (changed) {
800 changed = false;
801 changed |= ruleR1_(pag);
802 changed |= ruleR2_(pag);
803 changed |= ruleR3_(pag);
804 changed |= ruleR4_(pag);
805 changed |= ruleR5_(pag);
806 changed |= ruleR6_(pag);
807 changed |= ruleR7_(pag);
808 changed |= ruleR8_(pag);
809 changed |= ruleR9_(pag);
810 changed |= ruleR10_(pag);
811 }
812 }
813
814 // ##########################################################################
815 // Primary output — learnPAG
816 // ##########################################################################
817
819 // Phase 1: skeleton discovery
820 MixedGraph skeleton = learnSkeleton(std::move(graph));
822
823 // Build initial PAG from skeleton
824 PAG pag;
825 for (const NodeId n: skeleton.nodes()) {
826 pag.addNodeWithId(n);
827 }
828 // undirected skeleton edges → Circle-Circle
829 for (const Edge& e: skeleton.edges()) {
830 pag.addEdge(e.first(), e.second());
831 }
832 // mandatory arcs → Tail-Arrowhead
833 for (const Arc& a: skeleton.arcs()) {
834 pag.addEdge(a.tail(), a.head(), EdgeMark::Tail, EdgeMark::Arrowhead);
835 }
836
837 // Phase 2: orient unshielded colliders on PAG
838 orientCollidersOnPAG_(pag, skeleton);
839
840 // Phase 3: possibleDSep pruning
842
843 // Phase 4: reset marks to Circle, restore mandatory arcs, re-orient colliders
845 for (const Arc& a: skeleton.arcs()) {
846 if (pag.existsEdge(a.tail(), a.head())) {
847 pag.setMarkAt(a.tail(), a.head(), EdgeMark::Arrowhead);
848 pag.setMarkAt(a.head(), a.tail(), EdgeMark::Tail);
849 }
850 }
852
853 // Phase 5: orientation rules R1–R10 (fixed-point)
855
856 // Phase 6: collect bidirected edges (↔) as latent couples
857 _latentCouples_.clear();
858 for (const Edge& e: pag.edges()) {
859 if (pag.isBidirected(e.first(), e.second())) {
860 _latentCouples_.emplace_back(e.first(), e.second());
861 }
862 }
863
864 return pag;
865 }
866
867 // ##########################################################################
868 // Bridge — learnMixedStructure
869 // ##########################################################################
870
872 PAG pag = learnPAG(std::move(graph));
873 MixedGraph mg = pag.toMixedGraph();
875 return mg;
876 }
877
878} // namespace gum::learning
FCI (Fast Causal Inference) causal discovery algorithm.
const ArcSet & arcs() const
returns the set of arcs stored within the ArcGraphPart
The base class for all directed edges.
const EdgeSet & edges() const
returns the set of edges stored within the EdgeGraphPart
bool existsEdge(const Edge &edge) const
indicates whether a given edge exists
const NodeSet & neighbours(NodeId id) const
returns the set of node neighbours to a given node
The base class for all undirected edges.
value_type & insert(const Key &key, const Val &val)
Adds a new element (actually a copy of this element) into the hash table.
void set(const Key &key, const Val &default_value)
Add a new property or modify it if it already existed.
bool exists(const Key &key) const
Checks whether there exists an element with a given key in the hashtable.
Base class for mixed graphs.
Definition mixedGraph.h:146
const NodeGraphPart & nodes() const
return *this as a NodeGraphPart
virtual void addNodeWithId(const NodeId id)
try to insert a node with the given id
Partial Ancestral Graph: undirected topology with endpoint marks.
Definition PAG.h:90
bool isCircle(NodeId src, NodeId dst) const
true if mark at dst (from src) is Circle
Definition PAG.cpp:148
bool isBidirected(NodeId x, NodeId y) const
true if edge x-y is bidirected x↔y (Arrowhead on both endpoints)
Definition PAG.cpp:155
bool isDefCollider(NodeId x, NodeId z, NodeId y) const
true if z is a definite collider on path x-z-y (Arrowhead at z from both sides)
Definition PAG.cpp:159
void reorientAllWith(EdgeMark m)
set all endpoint marks to m (used between FCI phases to reset to Circle)
Definition PAG.cpp:168
void addEdge(NodeId x, NodeId y) override
add edge with Circle-Circle marks
Definition PAG.cpp:91
void setMarkAt(NodeId src, NodeId dst, EdgeMark m)
set mark at dst-endpoint when traversing from src
Definition PAG.cpp:136
bool isTail(NodeId src, NodeId dst) const
true if mark at dst (from src) is Tail
Definition PAG.cpp:146
void eraseEdge(const Edge &e) override
remove edge and its two mark entries
Definition PAG.cpp:101
bool isArrowhead(NodeId src, NodeId dst) const
true if mark at dst (from src) is Arrowhead
Definition PAG.cpp:142
MixedGraph toMixedGraph() const
approximate conversion for learnDAG/learnPDAG: Tail-Arrowhead → arc; Arrowhead-Arrowhead → two arcs; ...
Definition PAG.cpp:179
static const iterator & end() noexcept
The usual unsafe end iterator to parse the set.
Definition set_tpl.h:421
iterator begin() const
The usual unsafe begin iterator to parse the set.
Definition set_tpl.h:409
@ Standard
process triples in natural traversal order
CIBasedLearning & operator=(const CIBasedLearning &)
MixedGraph learnSkeleton(MixedGraph graph) override
Phase 1: skeleton discovery via conditional independence tests.
HashTable< std::pair< NodeId, NodeId >, SepSetEntry_ > sepSet_
std::vector< ThreePoints > unshieldedTriples_(const MixedGraph &graph)
Builds a complete MixedGraph on the nodes of template_graph, minus edges forbidden by structural cons...
void orientDoubleHeadedArcs_(MixedGraph &mg)
Builds a complete MixedGraph on the nodes of template_graph, minus edges forbidden by structural cons...
void applyStructuralConstraints_(MixedGraph &graph)
Builds a complete MixedGraph on the nodes of template_graph, minus edges forbidden by structural cons...
PAG learnPAG(MixedGraph graph)
primary FCI output: learn a PAG from the given node-only or partial graph
Definition FCI.cpp:818
std::vector< NodeId > possibleDSep(const PAG &pag, NodeId x, NodeId y) const
learnSkeleton() inherited from CIBasedLearning
Definition FCI.cpp:86
bool doDdpOrientation_(PAG &pag, NodeId d, NodeId a, NodeId b, NodeId c, const HashTable< NodeId, NodeId > &previous) const
R4 helper: orient B,C in a detected discriminating path D..A,B,C.
Definition FCI.cpp:539
bool ruleR7_(PAG &pag) const
tail propagation from A-oB
Definition FCI.cpp:512
bool ruleR1_(PAG &pag) const
away from collider
Definition FCI.cpp:354
FCI & operator=(const FCI &)
Definition FCI.cpp:62
Size maxPathLength() const
maximum path length for R4 discriminating-path search; Size(-1) = unlimited
Definition FCI.cpp:84
bool ruleR3_(PAG &pag) const
double triangle
Definition FCI.cpp:405
std::vector< NodeId > computePossibleDSep_(const PAG &pag, NodeId x, NodeId y) const
Phase 3: return all nodes on possible-d-sep paths from x toward y (excl. x, y).
Definition FCI.cpp:248
bool ruleR5_(PAG &pag) const
uncovered circle path → undirected
Definition FCI.cpp:449
void orientCollidersOnPAG_(PAG &pag, const MixedGraph &topology)
Phase 2 / Phase 4: orient unshielded colliders directly on the PAG. topology is used for unshielded-t...
Definition FCI.cpp:102
bool ruleR9_(PAG &pag) const
away from ancestor via uncovered pd-path
Definition FCI.cpp:719
bool ruleR6_(PAG &pag) const
tail propagation from undirected edge
Definition FCI.cpp:489
Size maxPathLength_
Definition FCI.h:165
void applyOrientationRules_(PAG &pag) const
Phase 5: fixed-point loop over all orientation rules R1–R10.
Definition FCI.cpp:797
bool ruleR8_(PAG &pag) const
away from ancestor (graph only)
Definition FCI.cpp:693
void resolveOrientConflict_(NodeId src, NodeId dst) override
conflict hook override: FCI leaves circle marks — orientation conflicts are not recorded as latent co...
Definition FCI.cpp:94
void possibleDSepPhase_(PAG &pag)
Phase 3: prune PAG edges using possibleDSep conditioning sets.
Definition FCI.cpp:287
bool ruleR2_(PAG &pag) const
away from cycle
Definition FCI.cpp:378
bool ruleR4_(PAG &pag) const
discriminating path (uses CI test)
Definition FCI.cpp:595
MixedGraph learnMixedStructure(MixedGraph graph) override
bridge for learnDAG/learnPDAG: runs learnPAG then converts via toMixedGraph()
Definition FCI.cpp:871
bool ruleR10_(PAG &pag) const
away from ancestor via two semi-directed paths
Definition FCI.cpp:744
void setMaxPathLength(Size maxLen)
maximum path length for R4 discriminating-path search; Size(-1) = unlimited
Definition FCI.cpp:82
std::size_t Size
In aGrUM, hashed values are unsigned long int.
Definition types.h:74
Size NodeId
Type for node ids.
include the inlined functions if necessary
Definition CSVParser.h:55
std::tuple< NodeId, NodeId, NodeId > ThreePoints
@ Arrowhead
Definition PAG.h:65