Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bnlearn/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@

import pgmpy
# Check version pgmpy
if version.parse(pgmpy.__version__) < version.parse("0.1.18"):
raise ImportError('[bnlearn] >Error: This release requires pgmpy to be version == 0.1.25. Try to: <pip install -U pgmpy==0.1.25>')
if version.parse(pgmpy.__version__) < version.parse("1.1.2"):
raise ImportError('[bnlearn] >Error: This release requires pgmpy version >= 1.1.2. Try to: <pip install -U "pgmpy>=1.1.2,<1.2">')

# Version check
import matplotlib
Expand Down
43 changes: 28 additions & 15 deletions bnlearn/bnlearn.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@
from itertools import product
from collections import defaultdict

from pgmpy.models import BayesianNetwork, NaiveBayes, MarkovNetwork
from pgmpy.models import DiscreteBayesianNetwork, NaiveBayes, DiscreteMarkovNetwork
from pgmpy.models import DynamicBayesianNetwork as DBN
from pgmpy.factors.discrete import TabularCPD
from pgmpy.metrics import structure_score
from pgmpy.metrics import StructureScore

from setgraphviz import setgraphviz
from ismember import ismember
Expand Down Expand Up @@ -71,7 +71,7 @@ def to_bayesiannetwork(model, verbose=3):
# Convert to vector
vec = adjmat2vec(adjmat)[['source', 'target']].values.tolist()
# Make BayesianNetwork
bayesianmodel = BayesianNetwork(vec)
bayesianmodel = DiscreteBayesianNetwork(vec)
# Add any nodes from the adjmat that have no incoming or outgoing edges
# (isolated nodes); otherwise they would silently disappear since
# adjmat2vec() only returns edges.
Expand Down Expand Up @@ -222,7 +222,7 @@ def make_DAG(DAG, CPD=None, methodtype='bayes', isolated_nodes=None, checkmodel=
elif isinstance(DAG, list) and methodtype == 'bayes':
if verbose>=3: print('[bnlearn] >%s DAG created.' %(methodtype))
edges = DAG
DAG = BayesianNetwork()
DAG = DiscreteBayesianNetwork()
DAG.add_edges_from(edges)
if isolated_nodes is not None: DAG.add_nodes_from(isolated_nodes)
# DAG.add_nodes_from(CPD)
Expand All @@ -232,7 +232,7 @@ def make_DAG(DAG, CPD=None, methodtype='bayes', isolated_nodes=None, checkmodel=
if verbose>=3: print(f'[bnlearn] >[{methodtype}] is not supported to store the CPTs in the model.')
edges = DAG
# DAG = MarkovNetwork(DAG)
DAG = MarkovNetwork()
DAG = DiscreteMarkovNetwork()
DAG.add_edges_from(edges)
if isolated_nodes is not None: DAG.add_nodes_from(isolated_nodes)
# DAG.add_nodes_from(CPD)
Expand Down Expand Up @@ -346,8 +346,10 @@ def print_CPD(DAG, checkmodel=False, verbose=3):
DAG = DAG.get('model', None)

if ('markovnetwork' in str(type(DAG)).lower()):
if verbose>=3: print('[bnlearn] >Converting markovnetwork to Bayesian model')
DAG = DAG.to_bayesian_model()
# pgmpy 1.x removed MarkovNetwork.to_bayesian_model(); there is no
# equivalent conversion left to print CPDs from.
if verbose>=2: print('[bnlearn] >Warning: printing CPDs of a MarkovNetwork is no longer supported by pgmpy>=1.0. <return>')
return CPDs

if 'maximumlikelihood' in str(type(DAG)).lower():
# print CPDs using Maximum Likelihood Estimators
Expand Down Expand Up @@ -694,7 +696,7 @@ def _bif2bayesian(pathname, verbose=3):
bifmodel = BIFReader(path=pathname)

try:
model = BayesianNetwork(bifmodel.variable_edges)
model = DiscreteBayesianNetwork(bifmodel.variable_edges)
model.name = bifmodel.network_name
model.add_nodes_from(bifmodel.variable_names)

Expand Down Expand Up @@ -792,7 +794,7 @@ def _DAG_sprinkler(CPD=True):

"""
# Define the network structure
model = BayesianNetwork([('Cloudy', 'Sprinkler'),
model = DiscreteBayesianNetwork([('Cloudy', 'Sprinkler'),
('Cloudy', 'Rain'),
('Sprinkler', 'Wet_Grass'),
('Rain', 'Wet_Grass')])
Expand Down Expand Up @@ -1942,6 +1944,15 @@ def load(filepath='bnlearn_model.pkl', verbose=3):
# mods = pypickle.validate_modules(filepath)
model = pypickle.load(filepath, verbose=convert_verbose_to_new(verbose), validate=['builtins.int'])

# Models pickled under pgmpy 0.x (bnlearn<=0.13.x) resolve to pgmpy 1.x's bare
# tombstone classes: unpickling succeeds silently but returns a half-broken
# object that fails unpredictably later, so refuse it here with a clear message.
if isinstance(model, dict):
loaded_model = model.get('model', None)
if type(loaded_model).__name__ in ('BayesianNetwork', 'MarkovNetwork') and type(loaded_model).__module__.startswith('pgmpy'):
if verbose>=1: print('[bnlearn] >Error: [%s] was saved with bnlearn<=0.13.x (pgmpy 0.x) and cannot be loaded under pgmpy>=1.0. Re-learn and re-save the model with the current version, or load it using an older release: <pip install "bnlearn<0.14">' %(filepath))
return None

# Store in self
if model is not None:
return model
Expand Down Expand Up @@ -2003,16 +2014,16 @@ def independence_test(model, df, test="chi_square", alpha=0.05, prune=False, ver

"""
# Imports
from pgmpy.models import BayesianNetwork
from pgmpy.models import DiscreteBayesianNetwork
from pgmpy.base import DAG
from lingam import DirectLiNGAM, ICALiNGAM

# Set params
if model.get('model', None) is None: raise ValueError('[bnlearn]> No model detected.')
if not isinstance(model['model'], (DAG, BayesianNetwork, DirectLiNGAM, ICALiNGAM)): raise ValueError("[bnlearn]> model must be an instance of pgmpy.base.DAG or pgmpy.models.BayesianNetwork. Got {type(model)}")
if not isinstance(df, pd.DataFrame): raise ValueError("[bnlearn]> data must be a pandas.DataFrame instance. Got {type(data)}")
if isinstance(model['model'], (DAG, BayesianNetwork)):
if not np.all(np.isin(model['model'].nodes(), df.columns)): raise ValueError("[bnlearn]> Missing columns in data. Can't find values for the following variables: { set(model.nodes()) - set(data.columns) }")
if not isinstance(model['model'], (DAG, DiscreteBayesianNetwork, DirectLiNGAM, ICALiNGAM)): raise ValueError(f"[bnlearn]> model must be an instance of pgmpy.base.DAG or pgmpy.models.DiscreteBayesianNetwork. Got {type(model['model'])}")
if not isinstance(df, pd.DataFrame): raise ValueError(f"[bnlearn]> data must be a pandas.DataFrame instance. Got {type(df)}")
if isinstance(model['model'], (DAG, DiscreteBayesianNetwork)):
if not np.all(np.isin(model['model'].nodes(), df.columns)): raise ValueError(f"[bnlearn]> Missing columns in data. Can't find values for the following variables: {set(model['model'].nodes()) - set(df.columns)}")

# Get a copy of the model
model_update = copy.deepcopy(model)
Expand Down Expand Up @@ -2194,7 +2205,9 @@ def structure_scores(model, df, scoring_method=['k2', 'bic', 'bdeu', 'bds'], ver
scoring_object = bn.structure_learning._SetScoringType(df, s, verbose=0, **kwargs)
scores[s] = scoring_object.score(model)
else:
scores[s] = structure_score(model, df, scoring_method=s)
# pgmpy 1.x disambiguated 'bic'/'aic' into discrete ('-d') and
# gaussian ('-g') variants; keep accepting the historic names.
scores[s] = StructureScore(scoring_method={'bic': 'bic-d', 'aic': 'aic-d'}.get(s, s)).evaluate(df, model)
except (ValueError, TypeError, np.linalg.LinAlgError) as e:
if verbose>=2 and show_message:
print(f'[bnlearn] >WARNING> {e}')
Expand Down
7 changes: 4 additions & 3 deletions bnlearn/parameter_learning.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@


# %% Libraries
from pgmpy.estimators import BayesianEstimator
from pgmpy.parameter_estimator import DiscreteBayesianEstimator
import bnlearn
import copy
import warnings
Expand Down Expand Up @@ -148,8 +148,9 @@ def fit(model, df, methodtype='bayes', scoretype='bdeu', smooth=None, n_jobs=-1,
if config['verbose']>=2: print(cpd)
elif config['method']=='bayes':
# Learning CPDs using Bayesian Parameter Estimation
model.fit(df, estimator=BayesianEstimator, prior_type=scoretype, equivalent_sample_size=1000, pseudo_counts=smooth, n_jobs=config['n_jobs'])
# model.fit(df, estimator=BayesianEstimator, prior_type="BDeu", equivalent_sample_size=1000, pseudo_counts=smooth)
# pgmpy 1.x expects an initialized estimator instance instead of a class plus kwargs.
estimator = DiscreteBayesianEstimator(prior_type=scoretype, equivalent_sample_size=1000, pseudo_counts=smooth, n_jobs=config['n_jobs'])
model.fit(df, estimator=estimator)
for cpd in model.get_cpds():
if config['verbose']>=2: print("[bnlearn] >CPD of {variable}:".format(variable=cpd.variable))
if config['verbose']>=2: print(cpd)
Expand Down
71 changes: 39 additions & 32 deletions bnlearn/structure_learning.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,21 @@
import numpy as np
import matplotlib.pyplot as plt

# Note: pgmpy 1.1 deprecates the estimator-style HillClimbSearch/PC in favour of
# sklearn-style classes in pgmpy.causal_discovery with a different interface.
# The estimator-style classes remain until pgmpy 1.3; the <1.2 pin covers us,
# and switching APIs is deferred until the causal_discovery interface settles.
# The score classes must come from pgmpy.estimators too: pgmpy 1.1 keeps two
# parallel score hierarchies, and the estimator-style search classes only
# accept scores from their own (pgmpy.estimators.StructureScore) lineage.
from pgmpy.estimators import ExhaustiveSearch, HillClimbSearch, TreeSearch
try:
from pgmpy.estimators import StructureScore
except ImportError:
# Compatibility with older pgmpy releases.
from pgmpy.estimators.StructureScore import StructureScore
from pgmpy.estimators import PC as ConstraintBasedEstimator
from pgmpy.estimators import AIC, BDeu, BDs, BIC, K2, StructureScore
from pgmpy.causal_discovery import ExpertKnowledge
from pgmpy.models import NaiveBayes

import lingam

import pgmpy
from packaging import version
if version.parse(pgmpy.__version__)>=version.parse("0.1.13"):
from pgmpy.estimators import PC as ConstraintBasedEstimator
else:
from pgmpy.estimators import ConstraintBasedEstimator

import bnlearn

Expand Down Expand Up @@ -559,11 +558,15 @@ def _constraintsearch(df, significance_level=0.05, ci_test='chi_square', n_jobs=
# Set search algorithm
model = ConstraintBasedEstimator(df)

# Estimate using chi_square
skel, seperating_sets = model.build_skeleton(significance_level=significance_level, ci_test=ci_test)
# pgmpy 1.x removed skeleton_to_pdag; estimate() runs the whole
# skeleton -> PDAG pipeline, so the conditional-independence tests happen here.
# variant='stable' matches the pre-pgmpy-1.x default (1.x defaults to 'parallel').
pdag = model.estimate(significance_level=significance_level, ci_test=ci_test, variant='stable', return_type='pdag', show_progress=verbose>=4)

# Orienting edges never changes adjacency, so the PDAG's undirected view is the
# skeleton. Deriving it here avoids a second, identical build_skeleton() pass.
skel = pdag.to_undirected()
if verbose>=4: print("Undirected edges: ", skel.edges())
pdag = model.skeleton_to_pdag(skel, seperating_sets)
if verbose>=4: print("PDAG edges: ", pdag.edges())
dag = pdag.to_dag()
if verbose>=4: print("DAG edges: ", dag.edges())
Expand All @@ -575,11 +578,9 @@ def _constraintsearch(df, significance_level=0.05, ci_test='chi_square', n_jobs=
out['dag'] = dag
out['dag_edges'] = dag.edges()

# Search using "estimate()" method provides a shorthand for the three steps above and directly returns a "BayesianNetwork"
best_model = model.estimate(significance_level=significance_level)
out['model'] = best_model
# The fully oriented DAG from the pipeline above is the estimated model.
out['model'] = dag

if verbose>=4: print(best_model.edges())
return out


Expand Down Expand Up @@ -634,15 +635,21 @@ def _hillclimbsearch(df,
# Set search algorithm
model = HillClimbSearch(df)

# Compute best DAG
if bw_list_method=='edges':
if (black_list is not None) or (white_list is not None):
if verbose >= 3: print('[bnlearn] >Filter edges based on black_list/white_list')
# best_model = model.estimate()
best_model = model.estimate(scoring_method=scoring_method, start_dag=start_dag, max_indegree=max_indegree, tabu_length=tabu_length, epsilon=epsilon, max_iter=max_iter, black_list=black_list, white_list=white_list, fixed_edges=fixed_edges, show_progress=False)
else:
# At this point, variables are readily filtered based on bw_list_method or not (if nothing defined).
best_model = model.estimate(scoring_method=scoring_method, start_dag=start_dag, max_indegree=max_indegree, tabu_length=tabu_length, epsilon=epsilon, max_iter=max_iter, fixed_edges=fixed_edges, show_progress=False)
# Map bnlearn's black_list/white_list/fixed_edges onto pgmpy's ExpertKnowledge:
# forbidden_edges <- black_list, search_space <- white_list, required_edges <- fixed_edges.
expert_knowledge = None
use_edge_lists = bw_list_method=='edges'
if use_edge_lists and ((black_list is not None) or (white_list is not None)):
if verbose >= 3: print('[bnlearn] >Filter edges based on black_list/white_list')
if (use_edge_lists and (black_list or white_list)) or fixed_edges:
expert_knowledge = ExpertKnowledge(
forbidden_edges=black_list if use_edge_lists else None,
search_space=white_list if use_edge_lists else None,
required_edges=fixed_edges if fixed_edges else None,
)

# Compute best DAG. At this point, variables are readily filtered based on bw_list_method or not (if nothing defined).
best_model = model.estimate(scoring_method=scoring_method, start_dag=start_dag, max_indegree=max_indegree, tabu_length=tabu_length, epsilon=epsilon, max_iter=max_iter, expert_knowledge=expert_knowledge, show_progress=False)

# Ensure isolated variables are retained in sparse or empty DAGs.
best_model.add_nodes_from(df.columns)
Expand Down Expand Up @@ -751,15 +758,15 @@ def _SetScoringType(df, scoretype, verbose=3, **kwargs):
if verbose>=3: print('[bnlearn] >Set scoring type at [%s]' %(scoretype))

if scoretype=='bic':
scoring_method = pgmpy.estimators.BicScore(df)
scoring_method = BIC(df)
elif scoretype=='k2':
scoring_method = pgmpy.estimators.K2Score(df)
scoring_method = K2(df)
elif scoretype=='bdeu':
scoring_method = pgmpy.estimators.BDeuScore(df, equivalent_sample_size=5)
scoring_method = BDeu(df, equivalent_sample_size=5)
elif scoretype=='bds':
scoring_method = pgmpy.estimators.BDsScore(df, equivalent_sample_size=5)
scoring_method = BDs(df, equivalent_sample_size=5)
elif scoretype=='aic':
scoring_method = pgmpy.estimators.AICScore(df)
scoring_method = AIC(df)
elif scoretype=='loglik-g':
scoring_method = LogLikelihoodGauss(df, **kwargs)
elif scoretype=='aic-g':
Expand Down
4 changes: 2 additions & 2 deletions bnlearn/tests/test_bnlearn.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ def test_import_DAG():
# TEST 2: Check model output is unchanged
assert DAG['adjmat'].sum().sum() == 4
# TEST 3:
assert 'pgmpy.models.BayesianNetwork' in str(type(DAG['model']))
assert 'bayesiannetwork' in str(type(DAG['model'])).lower()
# TEST 4:
# DAG = bn.import_DAG('alarm', verbose=0)
# assert DAG.keys() == {'model', 'adjmat'}
Expand All @@ -134,7 +134,7 @@ def test_make_DAG():
DAG = bn.make_DAG(edges, methodtype=methodtype)
# TEST 1
if methodtype == 'bayes':
assert 'pgmpy.models.BayesianNetwork' in str(type(DAG['model']))
assert 'bayesiannetwork' in str(type(DAG['model'])).lower()
else:
assert 'pgmpy.models.NaiveBayes.NaiveBayes' in str(type(DAG['model']))
# TEST 2
Expand Down
15 changes: 15 additions & 0 deletions bnlearn/tests/test_pgmpy_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
before and after the migration.
"""
import os
import pickle
import shutil
import tempfile

Expand Down Expand Up @@ -140,6 +141,20 @@ def test_save_load_roundtrip(save_dir, sprinkler_model):
assert np.allclose(cpd.values, orig[cpd.variable])


def test_load_rejects_pgmpy_0x_pickle(save_dir, sprinkler_model):
# Models saved under pgmpy 0.x pickle the class path
# pgmpy.models.BayesianNetwork.BayesianNetwork, which in pgmpy 1.x resolves
# to a bare tombstone class: unpickling succeeds silently but yields a
# half-broken object. bn.load() must refuse it rather than hand it back.
from pgmpy.models.BayesianNetwork import BayesianNetwork
zombie = BayesianNetwork.__new__(BayesianNetwork)
zombie.__dict__.update(sprinkler_model['model'].__dict__)
filepath = os.path.join(save_dir, "legacy_model.pkl")
with open(filepath, "wb") as f:
pickle.dump({'model': zombie, 'adjmat': sprinkler_model['adjmat']}, f)
assert bn.load(filepath, verbose=0) is None


def test_structure_scores_all_discrete_methods(sprinkler_model, sprinkler_df):
scores = bn.structure_scores(sprinkler_model, sprinkler_df,
scoring_method=['k2', 'bic', 'bdeu', 'bds'], verbose=0)
Expand Down
2 changes: 1 addition & 1 deletion bnlearn/tests/test_structure_learning.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import pytest

from pgmpy.estimators import TreeSearch
from pgmpy.models import BayesianNetwork
from pgmpy.models import DiscreteBayesianNetwork
from pgmpy.inference import VariableElimination

@pytest.fixture
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ classifiers = [
]

dependencies = [
"pgmpy==0.1.25",
"pgmpy>=1.1.2,<1.2",
"networkx>=2.7.1",
"matplotlib>=3.3.4",
"numpy>=1.24.1",
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
pgmpy==0.1.25 # This is needed because above this version all kinds of third party google and LLM stuff is imported that we do not need.
pgmpy>=1.1.2,<1.2 # >=1.1 makes torch/litellm optional extras; <1.2 guards against pgmpy API churn between minors.
networkx>=2.7.1
matplotlib>=3.3.4
numpy>=1.24.1
Expand Down
Loading