Repository: scipy/scipy
Pull request: MAINT: stats: give frozen distributions pdf or pmf, not both
#### Reference issue
Closes gh-14827

#### What does this implement/fix?
Previously, all frozen distributions had attributes `pmf`, `pdf`, `logpmf`, and `logpdf`; only when these methods were called would an error occur because the underlying `rv_continuous` or `rv_discrete` did not have that method.
This change gives frozen continuous distributions only `pdf` and `logpdf` and frozen discrete distributions only `pmf` and `logpmf`.

#### Additional information
```python3
from scipy.stats import norm
norm().pmf  # previously no error, now AttributeError
norm().pmf()  # previously TypeError, now AttributeError
```

Problem statement:
BUG: Frozen distributions does not raise AttributeError on certain attributes
### Describe your issue.

Frozen distributions have a poor knowledge of its attributes, and won't raise `AttributeError` until it's called with appropriate arguments. `hasattr(frozen_dist, "pmf")` will return `True`, even if `pmf` is not applicable to the distribution.

---

Use case: I was comparing a discrete distribution with it's normal approximation, and was patching the frozen distribution so I could use `pmf` for continuous distributions.

```py
dists = [binom(n, p), norm(n*p, np.sqrt(n*p*(1-p)))]

def patch(dist):
    if not hasattr(dist, "pmf"):
        dist.pmf = dist.pdf

for dist in dists:
    patch(dist)
    dist.pmf(x)
```

### Reproducing Code Example

```python
# example distribution, applies to any? frozen distribution
from scipy.stats import norm

norm.pmf  # AttributeError
norm().pmf  # No error
norm().pmf()  # TypeError
norm().pmf(1)  # AttributeError
norm().pmf(1, 2)  # TypeError
norm().this_raises_attribute_error  # AttributeError

hasattr(norm, "pmf") == hasattr(norm(), "pmf")  # False
```


### Error message

```shell
# Error 1 (expected)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'norm_gen' object has no attribute 'pmf'

# Error 2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: pmf() missing 1 required positional argument: 'k'

# Error 3
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "C:\<snip>\site-packages\scipy\stats\_distn_infrastructure.py", line 505, in pmf
    return self.dist.pmf(k, *self.args, **self.kwds)
AttributeError: 'norm_gen' object has no attribute 'pmf'
```


### SciPy/NumPy/Python version information

1.7.1 1.21.2 sys.version_info(major=3, minor=8, micro=1, releaselevel='final', serial=0)

Hints from issue discussion:

Commit message: MAINT: stats: check distribution type with isinstance instead of hasattr

Patch to review:
diff --git a/scipy/stats/_distn_infrastructure.py b/scipy/stats/_distn_infrastructure.py
index 23f733e2025b..94797bb00493 100644
--- a/scipy/stats/_distn_infrastructure.py
+++ b/scipy/stats/_distn_infrastructure.py
@@ -449,12 +449,6 @@ def random_state(self):
     def random_state(self, seed):
         self.dist._random_state = check_random_state(seed)
 
-    def pdf(self, x):   # raises AttributeError in frozen discrete distribution
-        return self.dist.pdf(x, *self.args, **self.kwds)
-
-    def logpdf(self, x):
-        return self.dist.logpdf(x, *self.args, **self.kwds)
-
     def cdf(self, x):
         return self.dist.cdf(x, *self.args, **self.kwds)
 
@@ -501,12 +495,6 @@ def moment(self, order=None, **kwds):
     def entropy(self):
         return self.dist.entropy(*self.args, **self.kwds)
 
-    def pmf(self, k):
-        return self.dist.pmf(k, *self.args, **self.kwds)
-
-    def logpmf(self, k):
-        return self.dist.logpmf(k, *self.args, **self.kwds)
-
     def interval(self, confidence=None, **kwds):
         return self.dist.interval(confidence, *self.args, **self.kwds, **kwds)
 
@@ -526,6 +514,24 @@ def support(self):
         return self.dist.support(*self.args, **self.kwds)
 
 
+class rv_discrete_frozen(rv_frozen):
+
+    def pmf(self, k):
+        return self.dist.pmf(k, *self.args, **self.kwds)
+
+    def logpmf(self, k):  # No error
+        return self.dist.logpmf(k, *self.args, **self.kwds)
+
+
+class rv_continuous_frozen(rv_frozen):
+
+    def pdf(self, x):
+        return self.dist.pdf(x, *self.args, **self.kwds)
+
+    def logpdf(self, x):
+        return self.dist.logpdf(x, *self.args, **self.kwds)
+
+
 def argsreduce(cond, *args):
     """Clean arguments to:
 
@@ -860,7 +866,10 @@ def freeze(self, *args, **kwds):
             The frozen distribution.
 
         """
-        return rv_frozen(self, *args, **kwds)
+        if isinstance(self, rv_continuous):
+            return rv_continuous_frozen(self, *args, **kwds)
+        else:
+            return rv_discrete_frozen(self, *args, **kwds)
 
     def __call__(self, *args, **kwds):
         return self.freeze(*args, **kwds)
diff --git a/scipy/stats/tests/test_continuous_basic.py b/scipy/stats/tests/test_continuous_basic.py
index ba18ae645cae..f625a4df92d3 100644
--- a/scipy/stats/tests/test_continuous_basic.py
+++ b/scipy/stats/tests/test_continuous_basic.py
@@ -18,6 +18,7 @@
                            check_deprecation_warning_gh5982_moment,
                            check_deprecation_warning_gh5982_interval)
 from scipy.stats._distr_params import distcont
+from scipy.stats._distn_infrastructure import rv_continuous_frozen
 
 """
 Test all continuous distributions.
@@ -855,3 +856,17 @@ def test_kappa4_array_gh13582():
     k = np.array([-1, -0.5, 0, 1])[:, None]
     res2 = np.array(stats.kappa4.stats(h, k, moments=moments))
     assert res2.shape == (4, 4, 3)
+
+
+def test_frozen_attributes():
+    # gh-14827 reported that all frozen distributions had both pmf and pdf
+    # attributes; continuous should have pdf and discrete should have pmf.
+    message = "'rv_continuous_frozen' object has no attribute"
+    with pytest.raises(AttributeError, match=message):
+        stats.norm().pmf
+    with pytest.raises(AttributeError, match=message):
+        stats.norm().logpmf
+    stats.norm.pmf = "herring"
+    frozen_norm = stats.norm()
+    assert isinstance(frozen_norm, rv_continuous_frozen)
+    delattr(stats.norm, 'pmf')
\ No newline at end of file
diff --git a/scipy/stats/tests/test_discrete_basic.py b/scipy/stats/tests/test_discrete_basic.py
index 1c177bd6d6f0..0cbda6f7d8c7 100644
--- a/scipy/stats/tests/test_discrete_basic.py
+++ b/scipy/stats/tests/test_discrete_basic.py
@@ -12,6 +12,7 @@
                            check_deprecation_warning_gh5982_moment,
                            check_deprecation_warning_gh5982_interval)
 from scipy.stats._distr_params import distdiscrete, invdistdiscrete
+from scipy.stats._distn_infrastructure import rv_discrete_frozen
 
 vals = ([1, 2, 3, 4], [0.1, 0.2, 0.3, 0.4])
 distdiscrete += [[stats.rv_discrete(values=vals), ()]]
@@ -319,3 +320,17 @@ def test_cdf_gh13280_regression(distname, args):
     vals = dist.cdf(x, *args)
     expected = np.nan
     npt.assert_equal(vals, expected)
+
+
+def test_frozen_attributes():
+    # gh-14827 reported that all frozen distributions had both pmf and pdf
+    # attributes; continuous should have pdf and discrete should have pmf.
+    message = "'rv_discrete_frozen' object has no attribute"
+    with pytest.raises(AttributeError, match=message):
+        stats.binom(10, 0.5).pdf
+    with pytest.raises(AttributeError, match=message):
+        stats.binom(10, 0.5).logpdf
+    stats.binom.pdf = "herring"
+    frozen_binom = stats.binom(10, 0.5)
+    assert isinstance(frozen_binom, rv_discrete_frozen)
+    delattr(stats.binom, 'pdf')


Code review comments:
diff_hunk: @@ -865,3 +866,7 @@ def test_frozen_attributes():
         stats.norm().pmf
     with pytest.raises(AttributeError, match=message):
         stats.norm().logpmf
+    stats.norm.pmf = "herring"
+    frozen_norm = stats.norm()
+    assert isinstance(frozen_norm, rv_continuous_frozen)
+    delattr(stats.norm, 'pmf')
path: scipy/stats/tests/test_continuous_basic.py
text: @author:
```suggestion
    delattr(stats.norm, 'pmf')

```