From f7303a008f1e2efa00f837a7f695363559fc2b73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=8C=80=ED=98=84=20=28Kim=20DaeHyun=29?= Date: Fri, 21 Aug 2026 00:38:31 +0900 Subject: [PATCH 01/19] Feat: Add `sample()` method in `BaseParameter` --- pgmpy/parameter/_base.py | 12 ++++++++++++ pgmpy/tests/test_parameter/test_base.py | 8 ++++++++ 2 files changed, 20 insertions(+) diff --git a/pgmpy/parameter/_base.py b/pgmpy/parameter/_base.py index 80e35fd131..ac59302bce 100644 --- a/pgmpy/parameter/_base.py +++ b/pgmpy/parameter/_base.py @@ -63,6 +63,18 @@ def predict_proba(self, X): def _predict_proba(self, X): raise NotImplementedError + def sample(self, X): + if not self._is_fitted: + raise RuntimeError( + f"This {self.__class__.__name__} instance is not fitted yet. Call 'fit' before calling 'sample'." + ) + X, _, _ = self._check_data(X) + samples = self._sample(X) + return samples + + def _sample(self, X): + raise NotImplementedError + def _check_data(self, X, y=None, sample_weight=None): """check train data with tag""" # Check X diff --git a/pgmpy/tests/test_parameter/test_base.py b/pgmpy/tests/test_parameter/test_base.py index 88cd86e166..8cb63d20f7 100644 --- a/pgmpy/tests/test_parameter/test_base.py +++ b/pgmpy/tests/test_parameter/test_base.py @@ -36,6 +36,14 @@ def test_predict_proba(self): with pytest.raises(RuntimeError): parameter.predict_proba(X) + def test_sample(self): + X_arr, _ = make_moons(n_samples=100, noise=0.1, random_state=42) + X = pd.DataFrame(X_arr[:, 0].reshape(-1, 1), columns=["x"]) + parameter = BaseParameter() + + with pytest.raises(RuntimeError): + parameter.sample(X) + def test_check_data(self): def make_parameter(missing=False, can_be_root=True): class TempParameter(BaseParameter): From 97c48338369af541a55b3b20a17cc6b295f7c3b0 Mon Sep 17 00:00:00 2001 From: daehyun99 Date: Fri, 21 Aug 2026 23:10:35 +0900 Subject: [PATCH 02/19] Refactor `_check_data` --- pgmpy/parameter/_base.py | 104 ++++++++++------------- pgmpy/tests/test_parameter/test_base.py | 107 ++++++------------------ 2 files changed, 71 insertions(+), 140 deletions(-) diff --git a/pgmpy/parameter/_base.py b/pgmpy/parameter/_base.py index ac59302bce..a388624789 100644 --- a/pgmpy/parameter/_base.py +++ b/pgmpy/parameter/_base.py @@ -1,7 +1,8 @@ import numpy as np -import pandas as pd from skbase.base import BaseEstimator as _BaseEstimator +from pgmpy.causal_discovery._base import BaseCausalDiscovery + class BaseParameter(_BaseEstimator): """Base class for all parameter classes in pgmpy.""" @@ -32,7 +33,7 @@ def fit(self, X, y=None, sample_weight=None): ------- self : reference to self """ - X, y, sample_weight = self._check_data(X, y, sample_weight) + X, y, sample_weight = self._check_fit_data(X, y, sample_weight) self._fit(X, y, sample_weight) self._is_fitted = True return self @@ -40,7 +41,7 @@ def fit(self, X, y=None, sample_weight=None): def _fit(self, X, y=None, sample_weight=None): raise NotImplementedError - def predict_proba(self, X): + def predict_proba(self, X=None, n_samples=None): """Predict distribution over labels for data from features. Parameters @@ -52,80 +53,38 @@ def predict_proba(self, X): ------- y : skpro's Distribution class """ - if not self._is_fitted: - raise RuntimeError( - f"This {self.__class__.__name__} instance is not fitted yet. Call 'fit' before calling 'predict_proba'." - ) - X, _, _ = self._check_data(X) + X, _, _ = self._check_predict_proba_data(X) y_pred = self._predict_proba(X) return y_pred def _predict_proba(self, X): raise NotImplementedError - def sample(self, X): - if not self._is_fitted: - raise RuntimeError( - f"This {self.__class__.__name__} instance is not fitted yet. Call 'fit' before calling 'sample'." - ) - X, _, _ = self._check_data(X) + def sample(self, X=None, n_samples=None): + X, _, _ = self._check_sample_data(X) samples = self._sample(X) return samples def _sample(self, X): raise NotImplementedError - def _check_data(self, X, y=None, sample_weight=None): + def _check_fit_data(self, X, y=None, sample_weight=None): """check train data with tag""" - # Check X - if isinstance(X, pd.Series): - X = X.to_frame() - - if not isinstance(X, pd.DataFrame): - raise TypeError("X must be a pandas DataFrame.") - - if X.shape[0] == 0: - raise ValueError("X must have at least one row.") - - if X.shape[1] == 0: - raise ValueError("X must have at least one column.") - - if not self.get_tag("missing"): - if X.isna().any().any(): - raise ValueError(f"{self.__class__.__name__} cannot deal with missing values.") + transformer = BaseCausalDiscovery() + X = transformer._check_fit_data(X) if y is None: - n_samples = len(X) + self.evidences_ = None + self.variables_ = transformer.feature_names_in_ else: - # Check y - if isinstance(y, pd.Series): - y = y.to_frame() - - if not isinstance(y, pd.DataFrame): - raise TypeError("y must be a pandas DataFrame.") - - if y.shape[0] == 0: - raise ValueError("y must have at least one row.") - - if y.shape[1] == 0: - raise ValueError("y must have at least one column.") - - if self.get_tag("missing") is not True: - if y.isna().any().any(): - raise ValueError(f"{self.__class__.__name__} cannot deal with missing values.") + self.evidences_ = transformer.feature_names_in_ + y = transformer._check_fit_data(y) + self.variables_ = transformer.feature_names_in_ - if y.shape[1] != 1: - raise ValueError(f"y must contain exactly one target column. Got {y.shape[1]} columns.") - - if len(X) != len(y): - raise ValueError(f"X and y must have the same number of rows. Got len(X)={len(X)}, len(y)={len(y)}.") - - if not X.index.equals(y.index): - raise ValueError("X and y must have the same index.") - - n_samples = len(y) + # TODO: Implement missing data in suvervised, unsuvervised learning # Check sample_weight + n_samples = len(X) if sample_weight is None: sample_weight = np.ones(n_samples, dtype=float) else: @@ -138,3 +97,32 @@ def _check_data(self, X, y=None, sample_weight=None): raise ValueError("sample_weight cannot contain negative values.") return X, y, sample_weight + + def _check_predict_proba_data(self, X=None, n_samples=None): + if not self._is_fitted: + raise RuntimeError( + f"This {self.__class__.__name__} instance is not fitted yet. Call 'fit' before calling 'predict_proba'." + ) + + def _check_sample_data(self, X=None, n_samples=None): + if not self._is_fitted: + raise RuntimeError( + f"This {self.__class__.__name__} instance is not fitted yet. Call 'fit' before calling 'sample'." + ) + # Supervised + if (X is None) or (len(X) == len(n_samples)): + raise ValueError + else: + if len(X) == 1: + # broadcast + ... + + else: + ... + + # Unsupervised + if (X is not None) or (n_samples is None): + raise ValueError + else: + # sampling + ... diff --git a/pgmpy/tests/test_parameter/test_base.py b/pgmpy/tests/test_parameter/test_base.py index 8cb63d20f7..bce1c2f4ef 100644 --- a/pgmpy/tests/test_parameter/test_base.py +++ b/pgmpy/tests/test_parameter/test_base.py @@ -44,7 +44,7 @@ def test_sample(self): with pytest.raises(RuntimeError): parameter.sample(X) - def test_check_data(self): + def test_check_fit_data(self): def make_parameter(missing=False, can_be_root=True): class TempParameter(BaseParameter): _tags = { @@ -68,133 +68,76 @@ def __init__(self, missing=False, can_be_root=True): parameter = make_parameter() X = pd.DataFrame({"x": [1.0, 2.0, 3.0]}) - out_X, out_y, out_weight = parameter._check_data(X) + out_X, out_y, out_weight = parameter._check_fit_data(X) pd.testing.assert_frame_equal(out_X, X) assert out_y is None np.testing.assert_array_equal(out_weight, np.ones(3)) - # Case 2: X Series is converted to DataFrame - parameter = make_parameter() - X = pd.Series([1.0, 2.0, 3.0], name="x") - - out_X, out_y, out_weight = parameter._check_data(X) - - assert isinstance(out_X, pd.DataFrame) - assert list(out_X.columns) == ["x"] - assert out_y is None - np.testing.assert_array_equal(out_weight, np.ones(3)) - # Case 3: X must be DataFrame parameter = make_parameter() - with pytest.raises(TypeError, match="X must be a pandas DataFrame"): - parameter._check_data([[1.0], [2.0]]) - # Case 4: X must have rows parameter = make_parameter() X = pd.DataFrame({"x": []}) - with pytest.raises(ValueError, match="X must have at least one row"): - parameter._check_data(X) + with pytest.raises(ValueError): + parameter._check_fit_data(X) # Case 5: X must have columns parameter = make_parameter() X = pd.DataFrame(index=[0, 1, 2]) - with pytest.raises(ValueError, match="X must have at least one column"): - parameter._check_data(X) + with pytest.raises(ValueError): + parameter._check_fit_data(X) # Case 6: X missing values are rejected parameter = make_parameter(missing=False) X = pd.DataFrame({"x": [1.0, np.nan, 3.0]}) - with pytest.raises(ValueError, match="cannot deal with missing values"): - parameter._check_data(X) + with pytest.raises(ValueError): + parameter._check_fit_data(X) - # Case 7: X missing values are allowed - parameter = make_parameter(missing=True) - X = pd.DataFrame({"x": [1.0, np.nan, 3.0]}) + # # Case 7: X missing values are allowed + # parameter = make_parameter(missing=True) + # X = pd.DataFrame({"x": [1.0, np.nan, 3.0]}) - out_X, out_y, out_weight = parameter._check_data(X) + # # out_X, out_y, out_weight = parameter._check_fit_data(X) - pd.testing.assert_frame_equal(out_X, X) - assert out_y is None - np.testing.assert_array_equal(out_weight, np.ones(3)) + # pd.testing.assert_frame_equal(out_X, X) + # assert out_y is None + # np.testing.assert_array_equal(out_weight, np.ones(3)) # Case 8: valid supervised data parameter = make_parameter() X = pd.DataFrame({"x1": [1.0, 2.0, 3.0]}) y = pd.DataFrame({"target": ["a", "b", "a"]}) - out_X, out_y, out_weight = parameter._check_data(X, y) + out_X, out_y, out_weight = parameter._check_fit_data(X, y) pd.testing.assert_frame_equal(out_X, X) pd.testing.assert_frame_equal(out_y, y) np.testing.assert_array_equal(out_weight, np.ones(3)) - # Case 9: y Series is converted to DataFrame - parameter = make_parameter() - X = pd.DataFrame({"x1": [1.0, 2.0, 3.0]}) - y = pd.Series(["a", "b", "a"], name="target") - - out_X, out_y, out_weight = parameter._check_data(X, y) - - assert isinstance(out_y, pd.DataFrame) - assert list(out_y.columns) == ["target"] - np.testing.assert_array_equal(out_weight, np.ones(3)) - # Case 10: y must be DataFrame parameter = make_parameter() X = pd.DataFrame({"x1": [1.0, 2.0, 3.0]}) - with pytest.raises(TypeError, match="y must be a pandas DataFrame"): - parameter._check_data(X, ["a", "b", "a"]) - # Case 11: y must have rows parameter = make_parameter() X = pd.DataFrame({"x1": []}) y = pd.DataFrame({"target": []}) - with pytest.raises(ValueError, match="X must have at least one row"): - parameter._check_data(X, y) + with pytest.raises(ValueError): + parameter._check_fit_data(X, y) # Case 12: y must have columns parameter = make_parameter() X = pd.DataFrame({"x1": [1.0, 2.0, 3.0]}) y = pd.DataFrame(index=[0, 1, 2]) - with pytest.raises(ValueError, match="y must have at least one column"): - parameter._check_data(X, y) - - # Case 13: y must have one target column - parameter = make_parameter() - X = pd.DataFrame({"x1": [1.0, 2.0, 3.0]}) - y = pd.DataFrame( - { - "target1": ["a", "b", "a"], - "target2": ["x", "y", "x"], - } - ) - - with pytest.raises(ValueError, match="y must contain exactly one target column"): - parameter._check_data(X, y) - - # Case 14: X and y length must match - parameter = make_parameter() - X = pd.DataFrame({"x1": [1.0, 2.0, 3.0]}) - y = pd.DataFrame({"target": ["a", "b"]}) - - with pytest.raises(ValueError, match="X and y must have the same number of rows"): - parameter._check_data(X, y) - - # Case 15: X and y index must match - parameter = make_parameter() - X = pd.DataFrame({"x1": [1.0, 2.0, 3.0]}, index=[0, 1, 2]) - y = pd.DataFrame({"target": ["a", "b", "a"]}, index=[10, 11, 12]) - - with pytest.raises(ValueError, match="X and y must have the same index"): - parameter._check_data(X, y) + with pytest.raises(ValueError): + parameter._check_fit_data(X, y) # Case 16: valid sample weight parameter = make_parameter() @@ -202,7 +145,7 @@ def __init__(self, missing=False, can_be_root=True): y = pd.DataFrame({"target": ["a", "b", "a"]}) sample_weight = [0.5, 1.0, 2.0] - out_X, out_y, out_weight = parameter._check_data(X, y, sample_weight) + out_X, out_y, out_weight = parameter._check_fit_data(X, y, sample_weight) np.testing.assert_array_equal(out_weight, np.array([0.5, 1.0, 2.0])) @@ -212,7 +155,7 @@ def __init__(self, missing=False, can_be_root=True): y = pd.DataFrame({"target": ["a", "b", "a"]}) sample_weight = np.array([[0.5], [1.0], [2.0]]) - out_X, out_y, out_weight = parameter._check_data(X, y, sample_weight) + out_X, out_y, out_weight = parameter._check_fit_data(X, y, sample_weight) np.testing.assert_array_equal(out_weight, np.array([0.5, 1.0, 2.0])) @@ -221,8 +164,8 @@ def __init__(self, missing=False, can_be_root=True): X = pd.DataFrame({"x1": [1.0, 2.0, 3.0]}) y = pd.DataFrame({"target": ["a", "b", "a"]}) - with pytest.raises(ValueError, match="sample_weight must have length 3"): - parameter._check_data(X, y, sample_weight=[1.0, 2.0]) + with pytest.raises(ValueError): + parameter._check_fit_data(X, y, sample_weight=[1.0, 2.0]) # Case 19: sample weight cannot be negative parameter = make_parameter() @@ -230,4 +173,4 @@ def __init__(self, missing=False, can_be_root=True): y = pd.DataFrame({"target": ["a", "b", "a"]}) with pytest.raises(ValueError, match="sample_weight cannot contain negative values"): - parameter._check_data(X, y, sample_weight=[1.0, -1.0, 2.0]) + parameter._check_fit_data(X, y, sample_weight=[1.0, -1.0, 2.0]) From 5a48dee1920efa320f1feb39808ec2d623a95b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=8C=80=ED=98=84=20=28Kim=20DaeHyun=29?= Date: Sat, 22 Aug 2026 00:42:37 +0900 Subject: [PATCH 03/19] Refactor `_tag` --- pgmpy/parameter/_base.py | 40 ++++++++++++++++++------- pgmpy/tests/test_parameter/test_base.py | 12 ++++---- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/pgmpy/parameter/_base.py b/pgmpy/parameter/_base.py index a388624789..08b04b9919 100644 --- a/pgmpy/parameter/_base.py +++ b/pgmpy/parameter/_base.py @@ -8,12 +8,13 @@ class BaseParameter(_BaseEstimator): """Base class for all parameter classes in pgmpy.""" _tags = { - "parameter_type": str, # classifier, regressor - "produces_factor": bool, # Only for TabularCPD - "is_linear_gaussian": bool, # Only for LinearGaussianCPD - "supports_fit_joint": bool, # Only for FunctionalCPD - "missing": bool, - "can_be_root": bool, + "object_type": str, # {"parameter:continuous", "parameter:discrete", "parameter:mixture"} + "fit_mode": set, # {"supervise", "unsupervise", "untraninable"} + "python_dependencies": set, + "local:plug_in": set, + "global:plug_in": set, + "local:full_bayesian": set, + "global:full_bayesian": set, } def __init__(self): @@ -25,9 +26,8 @@ def fit(self, X, y=None, sample_weight=None): Parameters ---------- X : pandas DataFrame - feature instances to fit regressor to - y : pandas DataFrame, must be same length as X - labels to fit regressor to + + y : pandas DataFrame Returns ------- @@ -35,6 +35,12 @@ def fit(self, X, y=None, sample_weight=None): """ X, y, sample_weight = self._check_fit_data(X, y, sample_weight) self._fit(X, y, sample_weight) + + if hasattr(self, "object_type_") is False: + raise AttributeError(f"{self.__class__.__name__}._fit() must set the 'object_type_' attribute.") + if hasattr(self, "fit_mode_") is False: + raise AttributeError(f"{self.__class__.__name__}._fit() must set the 'fit_mode_' attribute.") + self._is_fitted = True return self @@ -47,7 +53,8 @@ def predict_proba(self, X=None, n_samples=None): Parameters ---------- X : pandas DataFrame, must have same columns as X in `fit` - data to predict labels for + + n_samples : int Returns ------- @@ -61,6 +68,19 @@ def _predict_proba(self, X): raise NotImplementedError def sample(self, X=None, n_samples=None): + """Sample labels from the predicted distribution for data from features. + + Parameters + ---------- + X : pandas DataFrame, must have same columns as X in `fit` + + n_samples : int + + Returns + ------- + y : pandas DataFrame + samples drawn from the predicted distribution + """ X, _, _ = self._check_sample_data(X) samples = self._sample(X) return samples diff --git a/pgmpy/tests/test_parameter/test_base.py b/pgmpy/tests/test_parameter/test_base.py index bce1c2f4ef..ba20307a12 100644 --- a/pgmpy/tests/test_parameter/test_base.py +++ b/pgmpy/tests/test_parameter/test_base.py @@ -13,11 +13,13 @@ def test_base_parameter_default(self): parameter = BaseParameter() assert parameter.__class__.__name__ == "BaseParameter" - assert parameter.get_class_tag("parameter_type") is str - assert parameter.get_class_tag("produces_factor") is bool - assert parameter.get_class_tag("missing") is bool - assert parameter.get_class_tag("is_linear_gaussian") is bool - assert parameter.get_class_tag("supports_fit_joint") is bool + assert parameter.get_class_tag("object_type") is str + assert parameter.get_class_tag("fit_mode") is set + assert parameter.get_class_tag("python_dependencies") is set + assert parameter.get_class_tag("local:plug_in") is set + assert parameter.get_class_tag("global:plug_in") is set + assert parameter.get_class_tag("local:full_bayesian") is set + assert parameter.get_class_tag("global:full_bayesian") is set def test_fit(self): X_arr, _ = make_moons(n_samples=100, noise=0.1, random_state=42) From 6043e64606625f6cd80a1452b02bad335b680539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=8C=80=ED=98=84=20=28Kim=20DaeHyun=29?= Date: Sat, 22 Aug 2026 01:29:43 +0900 Subject: [PATCH 04/19] Refactor `_check_fit_data` based on `_tags` --- pgmpy/parameter/_base.py | 39 +++++++++++++++--- pgmpy/tests/test_parameter/test_base.py | 53 +++++++++++++------------ 2 files changed, 60 insertions(+), 32 deletions(-) diff --git a/pgmpy/parameter/_base.py b/pgmpy/parameter/_base.py index 08b04b9919..bb97356505 100644 --- a/pgmpy/parameter/_base.py +++ b/pgmpy/parameter/_base.py @@ -1,4 +1,5 @@ import numpy as np +import pandas as pd from skbase.base import BaseEstimator as _BaseEstimator from pgmpy.causal_discovery._base import BaseCausalDiscovery @@ -8,7 +9,7 @@ class BaseParameter(_BaseEstimator): """Base class for all parameter classes in pgmpy.""" _tags = { - "object_type": str, # {"parameter:continuous", "parameter:discrete", "parameter:mixture"} + "object_type": str, # {"continuous", "discrete", "mixture"} "fit_mode": set, # {"supervise", "unsupervise", "untraninable"} "python_dependencies": set, "local:plug_in": set, @@ -90,16 +91,42 @@ def _sample(self, X): def _check_fit_data(self, X, y=None, sample_weight=None): """check train data with tag""" - transformer = BaseCausalDiscovery() - X = transformer._check_fit_data(X) - - if y is None: + if (y is None) and ("unsupervise" in self.get_class_tag("fit_mode")): + transformer = BaseCausalDiscovery() + X = transformer._check_fit_data(X) + self.fit_mode_ = "unsupervise" self.evidences_ = None self.variables_ = transformer.feature_names_in_ - else: + if (self.get_class_tag("object_type") == "continuous") and ( + pd.api.types.is_numeric_dtype(X[self.variables_[0]]) + ): + self.object_type_ = "continuous" + elif (self.get_class_tag("object_type") == "discrete") and ( + pd.api.types.is_string_dtype(X[self.variables_[0]]) + ): + self.object_type_ = "discrete" + else: + raise ValueError + + elif (y is not None) and ("supervise" in self.get_class_tag("fit_mode")): + transformer = BaseCausalDiscovery() + X = transformer._check_fit_data(X) + self.fit_mode_ = "supervise" self.evidences_ = transformer.feature_names_in_ y = transformer._check_fit_data(y) self.variables_ = transformer.feature_names_in_ + if (self.get_class_tag("object_type") == "continuous") and ( + pd.api.types.is_numeric_dtype(y[self.variables_[0]]) + ): + self.object_type_ = "continuous" + elif (self.get_class_tag("object_type") == "discrete") and ( + pd.api.types.is_string_dtype(y[self.variables_[0]]) + ): + self.object_type_ = "discrete" + else: + raise ValueError + else: + raise ValueError # TODO: Implement missing data in suvervised, unsuvervised learning diff --git a/pgmpy/tests/test_parameter/test_base.py b/pgmpy/tests/test_parameter/test_base.py index ba20307a12..bdee45454e 100644 --- a/pgmpy/tests/test_parameter/test_base.py +++ b/pgmpy/tests/test_parameter/test_base.py @@ -6,6 +6,26 @@ from pgmpy.parameter._base import BaseParameter +def make_parameter(object_type="continuous", fit_mode="unsupervise"): + class TempParameter(BaseParameter): + _tags = { + "object_type": object_type, # {"continuous", "discrete", "mixture"} + "fit_mode": fit_mode, # {"supervise", "unsupervise", "untraninable"} + "python_dependencies": set(), + "local:plug_in": set(), + "global:plug_in": set(), + "local:full_bayesian": set(), + "global:full_bayesian": set(), + } + + def __init__(self, object_type="continuous", fit_mode="unsupervise"): + super().__init__() + self.set_tags(object_type=object_type, fit_mode=fit_mode) + + parameter = TempParameter(object_type, fit_mode) + return parameter + + class TestBaseParameter: """Test BaseParameter class""" @@ -25,7 +45,7 @@ def test_fit(self): X_arr, _ = make_moons(n_samples=100, noise=0.1, random_state=42) X = pd.DataFrame(X_arr[:, 0].reshape(-1, 1), columns=["x"]) y = pd.DataFrame(X_arr[:, 1].reshape(-1, 1), columns=["y"]) - parameter = BaseParameter() + parameter = make_parameter(fit_mode="supervise") with pytest.raises(NotImplementedError): parameter.fit(X, y) @@ -47,25 +67,6 @@ def test_sample(self): parameter.sample(X) def test_check_fit_data(self): - def make_parameter(missing=False, can_be_root=True): - class TempParameter(BaseParameter): - _tags = { - "parameter_type": "discrete", - "produces_factor": False, - "is_linear_gaussian": False, - "missing": missing, - "supports_fit_joint": False, - "can_be_root": can_be_root, - "python_dependencies": (), - } - - def __init__(self, missing=False, can_be_root=True): - super().__init__() - self.set_tags(missing=missing, can_be_root=can_be_root) - - parameter = TempParameter(missing, can_be_root) - return parameter - # Case 1: valid unsupervised data parameter = make_parameter() X = pd.DataFrame({"x": [1.0, 2.0, 3.0]}) @@ -94,7 +95,7 @@ def __init__(self, missing=False, can_be_root=True): parameter._check_fit_data(X) # Case 6: X missing values are rejected - parameter = make_parameter(missing=False) + parameter = make_parameter() X = pd.DataFrame({"x": [1.0, np.nan, 3.0]}) with pytest.raises(ValueError): @@ -111,7 +112,7 @@ def __init__(self, missing=False, can_be_root=True): # np.testing.assert_array_equal(out_weight, np.ones(3)) # Case 8: valid supervised data - parameter = make_parameter() + parameter = make_parameter(object_type="discrete", fit_mode="supervise") X = pd.DataFrame({"x1": [1.0, 2.0, 3.0]}) y = pd.DataFrame({"target": ["a", "b", "a"]}) @@ -142,7 +143,7 @@ def __init__(self, missing=False, can_be_root=True): parameter._check_fit_data(X, y) # Case 16: valid sample weight - parameter = make_parameter() + parameter = make_parameter(object_type="discrete", fit_mode="supervise") X = pd.DataFrame({"x1": [1.0, 2.0, 3.0]}) y = pd.DataFrame({"target": ["a", "b", "a"]}) sample_weight = [0.5, 1.0, 2.0] @@ -152,7 +153,7 @@ def __init__(self, missing=False, can_be_root=True): np.testing.assert_array_equal(out_weight, np.array([0.5, 1.0, 2.0])) # Case 17: sample weight is flattened - parameter = make_parameter() + parameter = make_parameter(object_type="discrete", fit_mode="supervise") X = pd.DataFrame({"x1": [1.0, 2.0, 3.0]}) y = pd.DataFrame({"target": ["a", "b", "a"]}) sample_weight = np.array([[0.5], [1.0], [2.0]]) @@ -162,7 +163,7 @@ def __init__(self, missing=False, can_be_root=True): np.testing.assert_array_equal(out_weight, np.array([0.5, 1.0, 2.0])) # Case 18: sample weight length must match - parameter = make_parameter() + parameter = make_parameter(object_type="discrete", fit_mode="supervise") X = pd.DataFrame({"x1": [1.0, 2.0, 3.0]}) y = pd.DataFrame({"target": ["a", "b", "a"]}) @@ -170,7 +171,7 @@ def __init__(self, missing=False, can_be_root=True): parameter._check_fit_data(X, y, sample_weight=[1.0, 2.0]) # Case 19: sample weight cannot be negative - parameter = make_parameter() + parameter = make_parameter(object_type="discrete", fit_mode="supervise") X = pd.DataFrame({"x1": [1.0, 2.0, 3.0]}) y = pd.DataFrame({"target": ["a", "b", "a"]}) From 365b7f194b0f0a4c940201081b98f73383d9b119 Mon Sep 17 00:00:00 2001 From: daehyun99 Date: Sat, 22 Aug 2026 19:05:03 +0900 Subject: [PATCH 05/19] Test: Add test code for `predict_proba()`, `sample()` --- pgmpy/parameter/_base.py | 6 +- pgmpy/tests/test_parameter/test_base.py | 114 ++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/pgmpy/parameter/_base.py b/pgmpy/parameter/_base.py index bb97356505..787bd51ad5 100644 --- a/pgmpy/parameter/_base.py +++ b/pgmpy/parameter/_base.py @@ -48,20 +48,18 @@ def fit(self, X, y=None, sample_weight=None): def _fit(self, X, y=None, sample_weight=None): raise NotImplementedError - def predict_proba(self, X=None, n_samples=None): + def predict_proba(self, X=None): """Predict distribution over labels for data from features. Parameters ---------- X : pandas DataFrame, must have same columns as X in `fit` - n_samples : int - Returns ------- y : skpro's Distribution class """ - X, _, _ = self._check_predict_proba_data(X) + X = self._check_predict_proba_data(X) y_pred = self._predict_proba(X) return y_pred diff --git a/pgmpy/tests/test_parameter/test_base.py b/pgmpy/tests/test_parameter/test_base.py index bdee45454e..f68e6a42e1 100644 --- a/pgmpy/tests/test_parameter/test_base.py +++ b/pgmpy/tests/test_parameter/test_base.py @@ -177,3 +177,117 @@ def test_check_fit_data(self): with pytest.raises(ValueError, match="sample_weight cannot contain negative values"): parameter._check_fit_data(X, y, sample_weight=[1.0, -1.0, 2.0]) + + def test_check_predict_proba_data(self): + discrete_X = pd.DataFrame( + { + "X": ["A", "B", "C"], + } + ) + continous_X = pd.DataFrame( + { + "X": [0.1, 0.5, 0.3], + } + ) + + parameter = make_parameter() + with pytest.raises(RuntimeError): + parameter.predict_proba() + + parameter._is_fitted = True + with pytest.raises(ValueError): + parameter.predict_proba() + + # # Case : unsupervise, continuous + parameter = make_parameter(object_type="continuous", fit_mode="unsupervise") + parameter._is_fitted = True + + with pytest.raises(NotImplementedError): + parameter.predict_proba(continous_X) + + # # Case : unsupervise, discrete + parameter = make_parameter(object_type="discrete", fit_mode="unsupervise") + parameter._is_fitted = True + + with pytest.raises(NotImplementedError): + parameter.predict_proba(discrete_X) + + # # Case : supervise, continuous + parameter = make_parameter(object_type="continuous", fit_mode="supervise") + parameter._is_fitted = True + + with pytest.raises(NotImplementedError): + parameter.predict_proba(continous_X) + + with pytest.raises(ValueError): + parameter.predict_proba(discrete_X) + + # # Case : supervise, discrete + parameter = make_parameter(object_type="discrete", fit_mode="supervise") + parameter._is_fitted = True + + with pytest.raises(NotImplementedError): + parameter.predict_proba(continous_X) + + with pytest.raises(ValueError): + parameter.predict_proba(discrete_X) + + def test_sample_data(self): + discrete_X = pd.DataFrame( + { + "X": ["A", "B", "C"], + } + ) + continous_X = pd.DataFrame( + { + "X": [0.1, 0.5, 0.3], + } + ) + + parameter = make_parameter() + with pytest.raises(RuntimeError): + parameter.sample() + + parameter._is_fitted = True + with pytest.raises(ValueError): + parameter.sample() + + # # Case : unsupervise, continuous + parameter = make_parameter(object_type="continuous", fit_mode="unsupervise") + parameter._is_fitted = True + + with pytest.raises(ValueError): + parameter.sample(continous_X) + + with pytest.raises(NotImplementedError): + parameter.sample(n_samples=3) + + # # Case : unsupervise, discrete + parameter = make_parameter(object_type="discrete", fit_mode="unsupervise") + parameter._is_fitted = True + + with pytest.raises(ValueError): + parameter.sample(discrete_X) + + with pytest.raises(NotImplementedError): + parameter.sample(n_samples=3) + + # # Case : supervise, continuous + parameter = make_parameter(object_type="continuous", fit_mode="supervise") + parameter._is_fitted = True + + with pytest.raises(NotImplementedError): + parameter.sample(continous_X) + + with pytest.raises(ValueError): + parameter.sample(n_samples=3) + + # # Case : supervise, discrete + parameter = make_parameter(object_type="discrete", fit_mode="supervise") + parameter._is_fitted = True + + with pytest.raises(NotImplementedError): + parameter.sample(discrete_X) + + with pytest.raises(ValueError): + parameter.sample(n_samples=3) From 4d75adb616b702f82a7cecf7a6e79d2e3ff862e9 Mon Sep 17 00:00:00 2001 From: daehyun99 Date: Sat, 22 Aug 2026 19:29:24 +0900 Subject: [PATCH 06/19] Refactoring checking logic based on tag --- pgmpy/parameter/_base.py | 43 +++++++++++++------------ pgmpy/tests/test_parameter/test_base.py | 30 ++++++++++++----- 2 files changed, 44 insertions(+), 29 deletions(-) diff --git a/pgmpy/parameter/_base.py b/pgmpy/parameter/_base.py index 787bd51ad5..26bb0fbda0 100644 --- a/pgmpy/parameter/_base.py +++ b/pgmpy/parameter/_base.py @@ -48,7 +48,7 @@ def fit(self, X, y=None, sample_weight=None): def _fit(self, X, y=None, sample_weight=None): raise NotImplementedError - def predict_proba(self, X=None): + def predict_proba(self, X): """Predict distribution over labels for data from features. Parameters @@ -80,11 +80,11 @@ def sample(self, X=None, n_samples=None): y : pandas DataFrame samples drawn from the predicted distribution """ - X, _, _ = self._check_sample_data(X) - samples = self._sample(X) + X, n_samples = self._check_sample_data(X, n_samples) + samples = self._sample(X, n_samples) return samples - def _sample(self, X): + def _sample(self, X, n_samples): raise NotImplementedError def _check_fit_data(self, X, y=None, sample_weight=None): @@ -113,11 +113,11 @@ def _check_fit_data(self, X, y=None, sample_weight=None): self.evidences_ = transformer.feature_names_in_ y = transformer._check_fit_data(y) self.variables_ = transformer.feature_names_in_ - if (self.get_class_tag("object_type") == "continuous") and ( + if ("continuous" in self.get_class_tag("object_type")) and ( pd.api.types.is_numeric_dtype(y[self.variables_[0]]) ): self.object_type_ = "continuous" - elif (self.get_class_tag("object_type") == "discrete") and ( + elif ("discrete" in self.get_class_tag("object_type")) and ( pd.api.types.is_string_dtype(y[self.variables_[0]]) ): self.object_type_ = "discrete" @@ -143,31 +143,32 @@ def _check_fit_data(self, X, y=None, sample_weight=None): return X, y, sample_weight - def _check_predict_proba_data(self, X=None, n_samples=None): + def _check_predict_proba_data(self, X): if not self._is_fitted: raise RuntimeError( f"This {self.__class__.__name__} instance is not fitted yet. Call 'fit' before calling 'predict_proba'." ) + if self.fit_mode_ in {"unsupervise", "untrainable"}: + raise NotImplementedError + + transformer = BaseCausalDiscovery() + X = transformer._check_fit_data(X) + return X def _check_sample_data(self, X=None, n_samples=None): if not self._is_fitted: raise RuntimeError( f"This {self.__class__.__name__} instance is not fitted yet. Call 'fit' before calling 'sample'." ) - # Supervised - if (X is None) or (len(X) == len(n_samples)): + if (X is None) and (n_samples is None): raise ValueError - else: - if len(X) == 1: - # broadcast - ... - else: - ... + if self.fit_mode_ in {"unsupervise", "untrainable"}: + if X is not None: + raise ValueError - # Unsupervised - if (X is not None) or (n_samples is None): - raise ValueError - else: - # sampling - ... + elif self.fit_mode_ == "supervise": + if X is None: + raise ValueError + + return X, n_samples diff --git a/pgmpy/tests/test_parameter/test_base.py b/pgmpy/tests/test_parameter/test_base.py index f68e6a42e1..4911c9a79c 100644 --- a/pgmpy/tests/test_parameter/test_base.py +++ b/pgmpy/tests/test_parameter/test_base.py @@ -192,15 +192,19 @@ def test_check_predict_proba_data(self): parameter = make_parameter() with pytest.raises(RuntimeError): - parameter.predict_proba() + parameter.predict_proba(discrete_X) parameter._is_fitted = True - with pytest.raises(ValueError): + parameter.object_type_ = "continuous" + parameter.fit_mode_ = "unsupervise" + with pytest.raises(TypeError): parameter.predict_proba() # # Case : unsupervise, continuous parameter = make_parameter(object_type="continuous", fit_mode="unsupervise") parameter._is_fitted = True + parameter.object_type_ = "continuous" + parameter.fit_mode_ = "unsupervise" with pytest.raises(NotImplementedError): parameter.predict_proba(continous_X) @@ -208,6 +212,8 @@ def test_check_predict_proba_data(self): # # Case : unsupervise, discrete parameter = make_parameter(object_type="discrete", fit_mode="unsupervise") parameter._is_fitted = True + parameter.object_type_ = "discrete" + parameter.fit_mode_ = "unsupervise" with pytest.raises(NotImplementedError): parameter.predict_proba(discrete_X) @@ -215,23 +221,21 @@ def test_check_predict_proba_data(self): # # Case : supervise, continuous parameter = make_parameter(object_type="continuous", fit_mode="supervise") parameter._is_fitted = True + parameter.object_type_ = "continuous" + parameter.fit_mode_ = "supervise" with pytest.raises(NotImplementedError): parameter.predict_proba(continous_X) - with pytest.raises(ValueError): - parameter.predict_proba(discrete_X) - # # Case : supervise, discrete parameter = make_parameter(object_type="discrete", fit_mode="supervise") parameter._is_fitted = True + parameter.object_type_ = "discrete" + parameter.fit_mode_ = "supervise" with pytest.raises(NotImplementedError): parameter.predict_proba(continous_X) - with pytest.raises(ValueError): - parameter.predict_proba(discrete_X) - def test_sample_data(self): discrete_X = pd.DataFrame( { @@ -249,12 +253,16 @@ def test_sample_data(self): parameter.sample() parameter._is_fitted = True + parameter.object_type_ = "continuous" + parameter.fit_mode_ = "unsupervise" with pytest.raises(ValueError): parameter.sample() # # Case : unsupervise, continuous parameter = make_parameter(object_type="continuous", fit_mode="unsupervise") parameter._is_fitted = True + parameter.object_type_ = "continuous" + parameter.fit_mode_ = "unsupervise" with pytest.raises(ValueError): parameter.sample(continous_X) @@ -265,6 +273,8 @@ def test_sample_data(self): # # Case : unsupervise, discrete parameter = make_parameter(object_type="discrete", fit_mode="unsupervise") parameter._is_fitted = True + parameter.object_type_ = "discrete" + parameter.fit_mode_ = "unsupervise" with pytest.raises(ValueError): parameter.sample(discrete_X) @@ -275,6 +285,8 @@ def test_sample_data(self): # # Case : supervise, continuous parameter = make_parameter(object_type="continuous", fit_mode="supervise") parameter._is_fitted = True + parameter.object_type_ = "continuous" + parameter.fit_mode_ = "supervise" with pytest.raises(NotImplementedError): parameter.sample(continous_X) @@ -285,6 +297,8 @@ def test_sample_data(self): # # Case : supervise, discrete parameter = make_parameter(object_type="discrete", fit_mode="supervise") parameter._is_fitted = True + parameter.object_type_ = "discrete" + parameter.fit_mode_ = "supervise" with pytest.raises(NotImplementedError): parameter.sample(discrete_X) From 590e020b2346dd0b16127e5db434370322971d48 Mon Sep 17 00:00:00 2001 From: daehyun99 Date: Sat, 22 Aug 2026 19:32:05 +0900 Subject: [PATCH 07/19] fix minor --- pgmpy/parameter/_base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pgmpy/parameter/_base.py b/pgmpy/parameter/_base.py index 26bb0fbda0..d52daa4873 100644 --- a/pgmpy/parameter/_base.py +++ b/pgmpy/parameter/_base.py @@ -95,11 +95,11 @@ def _check_fit_data(self, X, y=None, sample_weight=None): self.fit_mode_ = "unsupervise" self.evidences_ = None self.variables_ = transformer.feature_names_in_ - if (self.get_class_tag("object_type") == "continuous") and ( + if ("continuous" in self.get_class_tag("object_type")) and ( pd.api.types.is_numeric_dtype(X[self.variables_[0]]) ): self.object_type_ = "continuous" - elif (self.get_class_tag("object_type") == "discrete") and ( + elif ("discrete" in self.get_class_tag("object_type")) and ( pd.api.types.is_string_dtype(X[self.variables_[0]]) ): self.object_type_ = "discrete" From e4f5a4b11b02d0adc014404b4927a95b84b1ea4f Mon Sep 17 00:00:00 2001 From: daehyun99 Date: Sat, 22 Aug 2026 19:34:23 +0900 Subject: [PATCH 08/19] fix minor --- pgmpy/parameter/_base.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pgmpy/parameter/_base.py b/pgmpy/parameter/_base.py index d52daa4873..4699e86bd3 100644 --- a/pgmpy/parameter/_base.py +++ b/pgmpy/parameter/_base.py @@ -79,10 +79,12 @@ def sample(self, X=None, n_samples=None): ------- y : pandas DataFrame samples drawn from the predicted distribution + + log_proba : log pdf or log pmf """ X, n_samples = self._check_sample_data(X, n_samples) - samples = self._sample(X, n_samples) - return samples + samples, log_proba = self._sample(X, n_samples) + return samples, log_proba def _sample(self, X, n_samples): raise NotImplementedError From f9c2ca21fab3c22d6cc8c20486772541def1bf2e Mon Sep 17 00:00:00 2001 From: daehyun99 Date: Sat, 22 Aug 2026 19:38:39 +0900 Subject: [PATCH 09/19] Add validate logic --- pgmpy/parameter/_base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pgmpy/parameter/_base.py b/pgmpy/parameter/_base.py index 4699e86bd3..4e4ce0d8f9 100644 --- a/pgmpy/parameter/_base.py +++ b/pgmpy/parameter/_base.py @@ -172,5 +172,7 @@ def _check_sample_data(self, X=None, n_samples=None): elif self.fit_mode_ == "supervise": if X is None: raise ValueError + transformer = BaseCausalDiscovery() + X = transformer._check_fit_data(X) return X, n_samples From f75c2c99db538d027e979b48485c68ce9b91e1f1 Mon Sep 17 00:00:00 2001 From: daehyun99 Date: Sat, 22 Aug 2026 19:44:39 +0900 Subject: [PATCH 10/19] Add validate logic --- pgmpy/parameter/_base.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pgmpy/parameter/_base.py b/pgmpy/parameter/_base.py index 4e4ce0d8f9..f4494f40ae 100644 --- a/pgmpy/parameter/_base.py +++ b/pgmpy/parameter/_base.py @@ -152,9 +152,12 @@ def _check_predict_proba_data(self, X): ) if self.fit_mode_ in {"unsupervise", "untrainable"}: raise NotImplementedError + elif self.fit_mode_ == "supervise": + if X is None: + raise ValueError + transformer = BaseCausalDiscovery() + X = transformer._check_fit_data(X) - transformer = BaseCausalDiscovery() - X = transformer._check_fit_data(X) return X def _check_sample_data(self, X=None, n_samples=None): From bba32c1bb3727302ac015409feb54fef75818aee Mon Sep 17 00:00:00 2001 From: daehyun99 Date: Mon, 24 Aug 2026 14:59:44 +0900 Subject: [PATCH 11/19] Update docs --- pgmpy/parameter/_base.py | 64 ++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/pgmpy/parameter/_base.py b/pgmpy/parameter/_base.py index f4494f40ae..555468c232 100644 --- a/pgmpy/parameter/_base.py +++ b/pgmpy/parameter/_base.py @@ -22,17 +22,29 @@ def __init__(self): super().__init__() def fit(self, X, y=None, sample_weight=None): - """Fit parameter to training data. + """Fit parameters to training data. + + If a parameter corresponds to a root node of the Bayesian network, + only unsupervised learning is supported. + If a parameter does not correspond to a root node of the Bayesian network, + only supervised learning is supported. Parameters ---------- - X : pandas DataFrame + X : pandas.DataFrame + Input data for supervised or unsupervised learning. + + y : pandas.DataFrame, (default=None) + Target labels for supervised learning. + Must be None for unsupervised learning. - y : pandas DataFrame + sample_weight : numpy.ndarray, (default=None) + Sample weights. If None, all samples are given equal weight. Returns ------- - self : reference to self + self : pgmpy parameter class + Returns the parameter instance with the fitted attributes. """ X, y, sample_weight = self._check_fit_data(X, y, sample_weight) self._fit(X, y, sample_weight) @@ -49,15 +61,25 @@ def _fit(self, X, y=None, sample_weight=None): raise NotImplementedError def predict_proba(self, X): - """Predict distribution over labels for data from features. + """Return the predicted conditional probability distribution for the given data. + + Only supported when the parameter was fitted using supervised learning. + If the parameter was fitted using unsupervised learning, refer to the ``sample()`` method instead. Parameters ---------- - X : pandas DataFrame, must have same columns as X in `fit` + X : pandas.DataFrame + Input data used to predict the conditional probability distribution. + Must have the same columns as the training data. Returns ------- - y : skpro's Distribution class + y : skpro distribution class + Predicted conditional probability distribution. + + See Also + -------- + sample : Generate samples from the distribution and their corresponding log probabilities. """ X = self._check_predict_proba_data(X) y_pred = self._predict_proba(X) @@ -67,20 +89,36 @@ def _predict_proba(self, X): raise NotImplementedError def sample(self, X=None, n_samples=None): - """Sample labels from the predicted distribution for data from features. + """Draw samples from the probability distribution or conditional probability distribution. + + If the parameter was fitted using supervised learning, samples are drawn from + the conditional probability distribution given X. + If the parameter was fitted using unsupervised learning, samples are drawn from + the (unconditional) probability distribution. Parameters ---------- - X : pandas DataFrame, must have same columns as X in `fit` + X : pandas.DataFrame, (default=None) + Required when the parameter was fitted with supervised learning. + Raises an error if the parameter was fitted with unsupervised learning; + use ``n_samples`` instead. - n_samples : int + n_samples : int, (default=None) + Required when the parameter was fitted with unsupervised learning. + Raises an error if the parameter was fitted with supervised learning; + use ``X`` instead. Returns ------- - y : pandas DataFrame - samples drawn from the predicted distribution + y : pandas.DataFrame or torch.Tensor + Samples drawn from the distribution. + + log_proba : numpy.ndarray or torch.Tensor + Log pdf or log pmf values corresponding to the drawn samples. - log_proba : log pdf or log pmf + See Also + -------- + predict_proba : Return the predicted conditional probability distribution. """ X, n_samples = self._check_sample_data(X, n_samples) samples, log_proba = self._sample(X, n_samples) From b80cf5ff1cd1006b29200432bef57159b5ffc18b Mon Sep 17 00:00:00 2001 From: daehyun99 Date: Mon, 24 Aug 2026 15:28:04 +0900 Subject: [PATCH 12/19] Add error message --- pgmpy/parameter/_base.py | 39 ++++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/pgmpy/parameter/_base.py b/pgmpy/parameter/_base.py index 555468c232..c7ce70def2 100644 --- a/pgmpy/parameter/_base.py +++ b/pgmpy/parameter/_base.py @@ -144,7 +144,10 @@ def _check_fit_data(self, X, y=None, sample_weight=None): ): self.object_type_ = "discrete" else: - raise ValueError + raise ValueError( + f"This {self.__class__.__name__} instance only supports data of type " + f"{self.get_class_tag('object_type')}." + ) elif (y is not None) and ("supervise" in self.get_class_tag("fit_mode")): transformer = BaseCausalDiscovery() @@ -162,11 +165,16 @@ def _check_fit_data(self, X, y=None, sample_weight=None): ): self.object_type_ = "discrete" else: - raise ValueError + raise ValueError( + f"This {self.__class__.__name__} instance only supports data of type " + f"{self.get_class_tag('object_type')}." + ) else: - raise ValueError + raise ValueError( + f"This {self.__class__.__name__} instance only supports {self.get_class_tag('fit_mode')} learning." + ) - # TODO: Implement missing data in suvervised, unsuvervised learning + # TODO: Implement missing data in suvervised, unsuvervised learning (EM algo) # Check sample_weight n_samples = len(X) @@ -189,10 +197,10 @@ def _check_predict_proba_data(self, X): f"This {self.__class__.__name__} instance is not fitted yet. Call 'fit' before calling 'predict_proba'." ) if self.fit_mode_ in {"unsupervise", "untrainable"}: - raise NotImplementedError + raise NotImplementedError( + f"This {self.__class__.__name__} instance is {self.fit_mode_}. It does not support `predict_proba()`." + ) elif self.fit_mode_ == "supervise": - if X is None: - raise ValueError transformer = BaseCausalDiscovery() X = transformer._check_fit_data(X) @@ -203,17 +211,18 @@ def _check_sample_data(self, X=None, n_samples=None): raise RuntimeError( f"This {self.__class__.__name__} instance is not fitted yet. Call 'fit' before calling 'sample'." ) - if (X is None) and (n_samples is None): - raise ValueError - if self.fit_mode_ in {"unsupervise", "untrainable"}: - if X is not None: - raise ValueError + if (X is None) and (n_samples) and (self.fit_mode_ in {"unsupervise", "untrainable"}): + return X, n_samples - elif self.fit_mode_ == "supervise": - if X is None: - raise ValueError + elif (X) and (n_samples is None) and (self.fit_mode_ in {"supervise"}): transformer = BaseCausalDiscovery() X = transformer._check_fit_data(X) + return X, n_samples + else: + raise ValueError( + f"This {self.__class__.__name__} instance supports {self.fit_mode_}. " + f"Please use `X` or `n_samples` accordingly." + ) return X, n_samples From f64ed7c189802acf1917d0920ea4016507d772ce Mon Sep 17 00:00:00 2001 From: daehyun99 Date: Mon, 24 Aug 2026 15:38:41 +0900 Subject: [PATCH 13/19] fix minor error --- pgmpy/parameter/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pgmpy/parameter/_base.py b/pgmpy/parameter/_base.py index c7ce70def2..0fd7db2ffe 100644 --- a/pgmpy/parameter/_base.py +++ b/pgmpy/parameter/_base.py @@ -215,7 +215,7 @@ def _check_sample_data(self, X=None, n_samples=None): if (X is None) and (n_samples) and (self.fit_mode_ in {"unsupervise", "untrainable"}): return X, n_samples - elif (X) and (n_samples is None) and (self.fit_mode_ in {"supervise"}): + elif (X is not None) and (n_samples is None) and (self.fit_mode_ in {"supervise"}): transformer = BaseCausalDiscovery() X = transformer._check_fit_data(X) return X, n_samples From c2fcd4f043a96b57815174e42e4a29cc59d23394 Mon Sep 17 00:00:00 2001 From: daehyun99 Date: Mon, 24 Aug 2026 15:44:29 +0900 Subject: [PATCH 14/19] fix minor type --- pgmpy/parameter/_base.py | 2 +- pgmpy/tests/test_parameter/test_base.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pgmpy/parameter/_base.py b/pgmpy/parameter/_base.py index 0fd7db2ffe..8d768294cd 100644 --- a/pgmpy/parameter/_base.py +++ b/pgmpy/parameter/_base.py @@ -9,7 +9,7 @@ class BaseParameter(_BaseEstimator): """Base class for all parameter classes in pgmpy.""" _tags = { - "object_type": str, # {"continuous", "discrete", "mixture"} + "object_type": set, # {"continuous", "discrete", "mixture"} "fit_mode": set, # {"supervise", "unsupervise", "untraninable"} "python_dependencies": set, "local:plug_in": set, diff --git a/pgmpy/tests/test_parameter/test_base.py b/pgmpy/tests/test_parameter/test_base.py index 4911c9a79c..57c23b84b0 100644 --- a/pgmpy/tests/test_parameter/test_base.py +++ b/pgmpy/tests/test_parameter/test_base.py @@ -33,7 +33,7 @@ def test_base_parameter_default(self): parameter = BaseParameter() assert parameter.__class__.__name__ == "BaseParameter" - assert parameter.get_class_tag("object_type") is str + assert parameter.get_class_tag("object_type") is set assert parameter.get_class_tag("fit_mode") is set assert parameter.get_class_tag("python_dependencies") is set assert parameter.get_class_tag("local:plug_in") is set From 117cdbaa2f7f0b143062f0491b36cdbb14f1bf71 Mon Sep 17 00:00:00 2001 From: daehyun99 Date: Mon, 24 Aug 2026 16:56:01 +0900 Subject: [PATCH 15/19] update minor docs --- pgmpy/parameter/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pgmpy/parameter/_base.py b/pgmpy/parameter/_base.py index 8d768294cd..c15989fc68 100644 --- a/pgmpy/parameter/_base.py +++ b/pgmpy/parameter/_base.py @@ -110,7 +110,7 @@ def sample(self, X=None, n_samples=None): Returns ------- - y : pandas.DataFrame or torch.Tensor + y : numpy.ndarray or torch.Tensor Samples drawn from the distribution. log_proba : numpy.ndarray or torch.Tensor From 82e1a0d48b918f0faadebe008a3e36d553ba95fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=8C=80=ED=98=84=20=28Kim=20DaeHyun=29?= Date: Mon, 24 Aug 2026 23:19:43 +0900 Subject: [PATCH 16/19] fix minor error --- pgmpy/parameter/_base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pgmpy/parameter/_base.py b/pgmpy/parameter/_base.py index c15989fc68..382c807328 100644 --- a/pgmpy/parameter/_base.py +++ b/pgmpy/parameter/_base.py @@ -192,7 +192,7 @@ def _check_fit_data(self, X, y=None, sample_weight=None): return X, y, sample_weight def _check_predict_proba_data(self, X): - if not self._is_fitted: + if not self.is_fitted: raise RuntimeError( f"This {self.__class__.__name__} instance is not fitted yet. Call 'fit' before calling 'predict_proba'." ) @@ -207,7 +207,7 @@ def _check_predict_proba_data(self, X): return X def _check_sample_data(self, X=None, n_samples=None): - if not self._is_fitted: + if not self.is_fitted: raise RuntimeError( f"This {self.__class__.__name__} instance is not fitted yet. Call 'fit' before calling 'sample'." ) From a8385bc6523393378c34872c3659129629733c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=8C=80=ED=98=84=20=28Kim=20DaeHyun=29?= Date: Mon, 24 Aug 2026 23:59:24 +0900 Subject: [PATCH 17/19] Remove `PyroToSkpro` --- pgmpy/distributions/converter/PyroToSkpro.py | 139 --------------- .../test_distributions/test_PyroToSkpro.py | 164 ------------------ 2 files changed, 303 deletions(-) delete mode 100644 pgmpy/distributions/converter/PyroToSkpro.py delete mode 100644 pgmpy/tests/test_distributions/test_PyroToSkpro.py diff --git a/pgmpy/distributions/converter/PyroToSkpro.py b/pgmpy/distributions/converter/PyroToSkpro.py deleted file mode 100644 index ac4da1a3eb..0000000000 --- a/pgmpy/distributions/converter/PyroToSkpro.py +++ /dev/null @@ -1,139 +0,0 @@ -from skpro.distributions.normal import Normal as SkproNormal - - -def _convert_normal(samples, index, columns, name="obs"): - """ - Convert the posterior samples obtained from MCMC and SVI using Pyro - into the Normal distribution format provided by skpro. - """ - y_samples = samples[name] - mean = y_samples.mean(dim=0).detach().reshape(-1, 1).cpu().numpy() - sigma = y_samples.std(dim=0, unbiased=False).detach().reshape(-1, 1).cpu().numpy() - - return SkproNormal( - mu=mean, - sigma=sigma, - index=index, - columns=columns, - ) - - -class PyroToSkpro: - """ - Convert the posterior samples obtained from MCMC and SVI using Pyro into the distribution format provided by skpro. - - `add()` adds a new conversion method, - while `set()` specifies which conversion method to use when `convert()` is called. - `remove()` deletes a specific conversion method, and `get()` returns the list of available conversion methods. - - - Examples - -------- - Create an default `PyroToSkpro` - - >>> from pgmpy.distributions.converter.PyroToSkpro import PyroToSkpro - >>> import torch - >>> import pandas as pd - >>> import numpy as np - - >>> samples = { - ... "obs": torch.tensor( - ... [ - ... [1.0, 2.0], - ... [3.0, 4.0], - ... [5.0, 6.0], - ... ] - ... ) - ... } - >>> converter = PyroToSkpro("normal") - >>> normal = converter.convert(samples, pd.RangeIndex(0,2), ["normal"], "obs") - >>> normal # doctest: +SKIP - Normal() - - The currently implemented converter classes can be viewed using `get()`. - - >>> converter.get() - ['normal'] - - You can add a custom converter, retrieve the distribution produced by that converter, - and manage it using the `add()`, `set()`, and `remove()` methods. - The example below demonstrates how to add and use a custom converter that produces a Laplace distribution. - >>> from skpro.distributions.laplace import Laplace - - >>> def _convert_laplace(samples, index, columns, name="obs"): - ... y_samples = samples[name] - ... mean = y_samples.mean(dim=0).detach().reshape(-1, 1).cpu().numpy() - ... scale = y_samples.std(dim=0, unbiased=False).detach().reshape(-1, 1).cpu().numpy() - ... return Laplace( - ... mu=mean, - ... scale=scale, - ... index=index, - ... columns=columns, - ... ) - >>> converter = PyroToSkpro() - >>> converter.add("laplace", _convert_laplace) # doctest: +SKIP - >>> converter.remove("normal") # doctest: +SKIP - >>> converter.get() # doctest: +SKIP - ['laplace'] - >>> converter.set("laplace") # doctest: +SKIP - >>> laplace = converter.convert(samples, pd.RangeIndex(0,2), ["laplace"], "obs") # doctest: +SKIP - >>> laplace # doctest: +SKIP - Laplace() - - """ - - def __init__(self, posterior_type: str = "normal"): - _DEFAULT_CONVERTERS = { - "normal": _convert_normal, - } - - self.posterior_type = posterior_type - self._converters = _DEFAULT_CONVERTERS - - def convert(self, samples, index, columns, name="obs"): - """ - Converts Pyro posterior samples into the skpro distribution format. - """ - converter = self._converters.get(self.posterior_type) - - if converter is None: - raise ValueError(f"{self.posterior_type} is not ...") - - return converter(samples, index, columns, name=name) - - def add( - self, - posterior_type, - converter_fn, - ): - """ - Adds a new conversion method. - """ - if not callable(converter_fn): - raise TypeError - - self._converters[posterior_type] = converter_fn - return self - - def set(self, posterior_type): - """ - Specifies which conversion method to use when `convert()` is called. - """ - self.posterior_type = posterior_type - return self - - def remove( - self, - posterior_type, - ): - """ - Removes a specific conversion method. - """ - self._converters.pop(posterior_type) - return self - - def get(self): - """ - Returns a list of all available conversion methods. - """ - return list(self._converters.keys()) diff --git a/pgmpy/tests/test_distributions/test_PyroToSkpro.py b/pgmpy/tests/test_distributions/test_PyroToSkpro.py deleted file mode 100644 index 213629aa2f..0000000000 --- a/pgmpy/tests/test_distributions/test_PyroToSkpro.py +++ /dev/null @@ -1,164 +0,0 @@ -import math - -import numpy as np -import pandas as pd -import pytest -from skpro.distributions.laplace import Laplace -from skpro.distributions.normal import Normal - -from pgmpy.distributions.converter.PyroToSkpro import PyroToSkpro - -torch = pytest.importorskip("torch") -pyro = pytest.importorskip("pyro") - - -@pytest.fixture -def samples(): - samples = { - "obs": torch.tensor( - [ - [1.0, 2.0], - [3.0, 4.0], - [5.0, 6.0], - ] - ) - } - return samples - - -class TestPyroToSkpro: - def test_normal(self, samples): - - converter = PyroToSkpro("normal") - normal = converter.convert(samples, pd.RangeIndex(0, 2), ["normal"], "obs") - - assert isinstance(normal, Normal) - assert normal.shape == (2, 1) - np.testing.assert_allclose( - normal.mu, - np.array( - [ - [3.0], - [4.0], - ], - dtype=np.float32, - ), - ) - np.testing.assert_allclose( - normal.sigma, - np.array( - [ - [1.6329932], - [1.6329932], - ], - dtype=np.float32, - ), - rtol=1e-6, - atol=1e-7, - ) - - def test_add(self, samples): - def _convert_laplace(samples, index, columns, name="obs"): - y_samples = samples[name] - mean = y_samples.mean(dim=0).detach().reshape(-1, 1).cpu().numpy() - scale = (y_samples.std(dim=0, unbiased=False) / math.sqrt(2)).detach().reshape(-1, 1).cpu().numpy() - - return Laplace( - mu=mean, - scale=scale, - index=index, - columns=columns, - ) - - converter = PyroToSkpro("laplace").add("laplace", _convert_laplace) - laplace = converter.convert(samples, pd.RangeIndex(0, 2), ["laplace"], "obs") - - assert isinstance(laplace, Laplace) - assert laplace.shape == (2, 1) - np.testing.assert_allclose( - laplace.mu, - np.array( - [ - [3.0], - [4.0], - ], - dtype=np.float32, - ), - ) - np.testing.assert_allclose( - laplace.scale, - np.array( - [ - [1.1547006], - [1.1547006], - ], - dtype=np.float32, - ), - rtol=1e-6, - atol=1e-7, - ) - - def test_set(self, samples): - def _convert_laplace(samples, index, columns, name="obs"): - y_samples = samples[name] - mean = y_samples.mean(dim=0).detach().reshape(-1, 1).cpu().numpy() - scale = (y_samples.std(dim=0, unbiased=False) / math.sqrt(2)).detach().reshape(-1, 1).cpu().numpy() - - return Laplace( - mu=mean, - scale=scale, - index=index, - columns=columns, - ) - - converter = PyroToSkpro() - converter.add("laplace", _convert_laplace) - converter.set("laplace") - laplace = converter.convert(samples, pd.RangeIndex(0, 2), ["laplace"], "obs") - - assert isinstance(laplace, Laplace) - assert laplace.shape == (2, 1) - np.testing.assert_allclose( - laplace.mu, - np.array( - [ - [3.0], - [4.0], - ], - dtype=np.float32, - ), - ) - np.testing.assert_allclose( - laplace.scale, - np.array( - [ - [1.1547006], - [1.1547006], - ], - dtype=np.float32, - ), - rtol=1e-6, - atol=1e-7, - ) - - def test_remove(self, samples): - def _convert_laplace(samples, index, columns, name="obs"): - return ... - - converter = PyroToSkpro() - converter.add("laplace", _convert_laplace) - converter.set("laplace") - converter.remove("laplace") - with pytest.raises(ValueError): - converter.convert(samples, pd.RangeIndex(0, 2), ["laplace"], "obs") - - def test_get(self, samples): - def _convert_laplace(samples, index, columns, name="obs"): - return ... - - converter = PyroToSkpro() - - assert converter.get() == ["normal"] - - converter.add("laplace", _convert_laplace) - assert converter.get() == ["normal", "laplace"] From 601b532dd55165c036f441459e79423493f0707f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=8C=80=ED=98=84=20=28Kim=20DaeHyun=29?= Date: Tue, 25 Aug 2026 00:01:47 +0900 Subject: [PATCH 18/19] `predict_proba()` -> `sample()` --- .../bayesian/BayesianFunctionalRegression.py | 29 ++++++------------- .../test_BayesianFunctionalRegression.py | 23 ++++++--------- 2 files changed, 18 insertions(+), 34 deletions(-) diff --git a/pgmpy/parameter/bayesian/BayesianFunctionalRegression.py b/pgmpy/parameter/bayesian/BayesianFunctionalRegression.py index d8852f0ac9..d9a0b8633d 100644 --- a/pgmpy/parameter/bayesian/BayesianFunctionalRegression.py +++ b/pgmpy/parameter/bayesian/BayesianFunctionalRegression.py @@ -2,7 +2,6 @@ from skbase.utils.dependencies import _check_soft_dependencies, _safe_import -from pgmpy.distributions.converter.PyroToSkpro import PyroToSkpro from pgmpy.parameter._base import BaseParameter torch = _safe_import("torch") @@ -49,11 +48,6 @@ class BayesianFunctionalRegression(BaseParameter): Number of posterior predictive samples to draw. When `estimator="mcmc"`, this is also the number of posterior samples generated during fitting. - posterior : str, default="normal" - Posterior distribution specification passed to - :class:`PyroToSkpro` for converting Pyro samples into an skpro - distribution. - return_sites : str, default="obs" Name of the Pyro sample site whose posterior predictive samples are returned. @@ -162,13 +156,13 @@ class BayesianFunctionalRegression(BaseParameter): """ _tags = { - "variable_type": "continuous", - "produces_factor": False, - "is_linear_gaussian": False, - "missing": False, - "supports_fit_joint": False, - "can_be_root": False, - "python_dependencies": ("pyro-ppl"), + "object_type": {"continuous"}, + "fit_mode": {"supervise"}, + "python_dependencies": ["pyro-ppl"], + "local:plug_in": set(), + "global:plug_in": set(), + "local:full_bayesian": {"mcmc", "svi"}, + "global:full_bayesian": set(), } def __init__( @@ -177,7 +171,6 @@ def __init__( estimator: Literal["svi", "mcmc"] = "svi", num_samples=1000, *, - posterior="normal", return_sites="obs", device="cpu", # MCMC @@ -196,7 +189,6 @@ def __init__( self.estimator = estimator self.num_samples = num_samples - self.posterior = posterior self.return_sites = return_sites self.device = device @@ -238,7 +230,6 @@ def _fit(self, X, y=None, sample_weight=None): if self.svi_log and j % 100 == 0: print(f"[iteration {j + 1:04d}] loss: {loss / X_tensor.shape[0]:.4f}") - self.posterior_ = PyroToSkpro(self.posterior) self.columns_ = y.columns[0] if self.device == "cpu": self.parallel_ = False @@ -246,8 +237,7 @@ def _fit(self, X, y=None, sample_weight=None): self.parallel_ = True return self - def _predict_proba(self, X): - index = X.index + def _sample(self, X=None, n_samples=None): X_tensor = to_tensor(X, self.device) if self.estimator == "mcmc": @@ -270,5 +260,4 @@ def _predict_proba(self, X): with torch.no_grad(): samples = predictive(X_tensor) - SkproDistribution = self.posterior_.convert(samples, index, [self.columns_], self.return_sites) - return SkproDistribution + return samples # log_proba diff --git a/pgmpy/tests/test_parameter/test_bayesian/test_BayesianFunctionalRegression.py b/pgmpy/tests/test_parameter/test_bayesian/test_BayesianFunctionalRegression.py index 93d8898378..d56238f57f 100644 --- a/pgmpy/tests/test_parameter/test_bayesian/test_BayesianFunctionalRegression.py +++ b/pgmpy/tests/test_parameter/test_bayesian/test_BayesianFunctionalRegression.py @@ -6,7 +6,6 @@ pyro = pytest.importorskip("pyro") dist = pytest.importorskip("pyro.distributions") -from skpro.distributions.normal import Normal as SkproNormal from pgmpy.parameter.bayesian.BayesianFunctionalRegression import BayesianFunctionalRegression @@ -84,13 +83,13 @@ def test_base_parameter_default(self, model): parameter = BayesianFunctionalRegression(model) assert parameter.__class__.__name__ == "BayesianFunctionalRegression" - assert parameter.get_class_tag("variable_type") == "continuous" - assert parameter.get_class_tag("produces_factor") is False - assert parameter.get_class_tag("missing") is False - assert parameter.get_class_tag("is_linear_gaussian") is False - assert parameter.get_class_tag("supports_fit_joint") is False - assert parameter.get_class_tag("can_be_root") is False - assert parameter.get_class_tag("python_dependencies") == ("pyro-ppl") + assert parameter.get_class_tag("object_type") == {"continuous"} + assert parameter.get_class_tag("fit_mode") == {"supervise"} + assert parameter.get_class_tag("python_dependencies") == ["pyro-ppl"] + assert parameter.get_class_tag("local:plug_in") == set() + assert parameter.get_class_tag("global:plug_in") == set() + assert parameter.get_class_tag("local:full_bayesian") == {"mcmc", "svi"} + assert parameter.get_class_tag("global:full_bayesian") == set() def test_fails(self, data, model): X, y = data @@ -124,7 +123,6 @@ def test_fit_svi(self, data, model): optim=pyro.optim.Adam({"lr": 0.05}), loss=pyro.infer.Trace_ELBO(), num_iterations=500, - posterior="normal", device="cpu", svi_log=True, ) @@ -157,9 +155,7 @@ def test_fit_svi(self, data, model): assert estimated_intercept == pytest.approx(0.3, abs=0.15) assert estimated_sigma == pytest.approx(0.7, abs=0.15) - distribution = parameter.predict_proba(X.iloc[:20]) - - assert isinstance(distribution, SkproNormal) + distribution = parameter.sample(X.iloc[:20]) assert distribution.shape == (20, 1) def test_fit_mcmc(self, data, model): @@ -207,7 +203,6 @@ def test_fit_mcmc(self, data, model): ) X_test = X.iloc[:20] - distribution = parameter.predict_proba(X_test) + distribution = parameter.sample(X_test) - assert isinstance(distribution, SkproNormal) assert distribution.shape == (20, 1) From 847f38a744aba3658c7784ad149e5b6463eb23ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=8C=80=ED=98=84=20=28Kim=20DaeHyun=29?= Date: Sun, 20 Sep 2026 12:02:27 +0900 Subject: [PATCH 19/19] Temp: Update BayesianFunctionalRegression --- .../bayesian/BayesianFunctionalRegression.py | 5 ++++- .../test_BayesianFunctionalRegression.py | 12 ++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/pgmpy/parameter/bayesian/BayesianFunctionalRegression.py b/pgmpy/parameter/bayesian/BayesianFunctionalRegression.py index d9a0b8633d..956d1ad0d3 100644 --- a/pgmpy/parameter/bayesian/BayesianFunctionalRegression.py +++ b/pgmpy/parameter/bayesian/BayesianFunctionalRegression.py @@ -260,4 +260,7 @@ def _sample(self, X=None, n_samples=None): with torch.no_grad(): samples = predictive(X_tensor) - return samples # log_proba + # TODO: Implement calculating log_proba logic + log_proba = None + + return samples, log_proba diff --git a/pgmpy/tests/test_parameter/test_bayesian/test_BayesianFunctionalRegression.py b/pgmpy/tests/test_parameter/test_bayesian/test_BayesianFunctionalRegression.py index d56238f57f..3d6a5a0a9d 100644 --- a/pgmpy/tests/test_parameter/test_bayesian/test_BayesianFunctionalRegression.py +++ b/pgmpy/tests/test_parameter/test_bayesian/test_BayesianFunctionalRegression.py @@ -155,8 +155,9 @@ def test_fit_svi(self, data, model): assert estimated_intercept == pytest.approx(0.3, abs=0.15) assert estimated_sigma == pytest.approx(0.7, abs=0.15) - distribution = parameter.sample(X.iloc[:20]) - assert distribution.shape == (20, 1) + samples, log_proba = parameter.sample(X.iloc[:20]) + assert torch.is_tensor(samples["obs"]) + assert log_proba is None def test_fit_mcmc(self, data, model): X, y = data @@ -202,7 +203,6 @@ def test_fit_mcmc(self, data, model): abs=0.1, ) - X_test = X.iloc[:20] - distribution = parameter.sample(X_test) - - assert distribution.shape == (20, 1) + samples, log_proba = parameter.sample(X.iloc[:20]) + assert torch.is_tensor(samples["obs"]) + assert log_proba is None