From 47575bb88f5787c36f27925d96a9f32f597a196d Mon Sep 17 00:00:00 2001 From: Kevin Sheppard Date: Mon, 12 Oct 2026 10:40:46 +0100 Subject: [PATCH 1/6] BUG: Validate the method of AbsorbingLS.fit AbsorbingLS.fit used LSMR for every name of a method other than "auto" and "hdfe". A misspelled name such as "hdfee", any other name such as "foo", and "HDFE" (which is a natural way to write the name) were accepted without an error or a warning and absorbed the variables using LSMR, which the user had not asked for. The method is now one of "auto", "hdfe" and "lsmr", whatever the case. Any other value, including a value that is not a string, raises a ValueError that names the accepted values and the value that was passed. The name is compared in lower case, so a call with "HDFE" after a call with "hdfe" does not absorb the variables again. --- linearmodels/iv/absorbing.py | 33 +++++- .../tests/iv/test_absorbing_validation.py | 107 ++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 linearmodels/tests/iv/test_absorbing_validation.py diff --git a/linearmodels/iv/absorbing.py b/linearmodels/iv/absorbing.py index 066e66153a..64777ac86b 100644 --- a/linearmodels/iv/absorbing.py +++ b/linearmodels/iv/absorbing.py @@ -136,6 +136,33 @@ def _options_key(options: dict[str, Any]) -> tuple[Any, ...]: return tuple(key) +def _check_method(method: str) -> str: + """ + Check the method used to absorb the variables + + Parameters + ---------- + method : str + The method that was passed to fit + + Returns + ------- + str + The method in lower case + + Raises + ------ + ValueError + If method is not "auto", "hdfe" or "lsmr", whatever its case + """ + if isinstance(method, str) and method.lower() in ("auto", "hdfe", "lsmr"): + return method.lower() + raise ValueError( + 'method must be one of "auto", "hdfe" or "lsmr", whatever its case. ' + f"Got {method!r}." + ) + + def _absorb_args( method: str, use_cache: bool, absorb_options: dict[str, Any] | None ) -> tuple[Any, ...]: @@ -1198,8 +1225,9 @@ def fit( debiased : bool Flag indicating whether to debiased the covariance estimator using a degree of freedom adjustment. - method : str - One of: + method : {"auto", "hdfe", "lsmr"}, optional + The algorithm that absorbs the variables. The name is not case + sensitive, and a ValueError is raised for any other value. One of: * "auto" - (Default). Use HDFE when applicable and fallback to LSMR. * "lsmr" - Force LSMR. @@ -1276,6 +1304,7 @@ def fit( linearmodels.iv.covariance.KernelCovariance linearmodels.iv.covariance.ClusteredCovariance """ + method = _check_method(method) if lsmr_options and absorb_options is None: # The options are options of LSMR, so they can not be passed to HDFE if method == "hdfe": diff --git a/linearmodels/tests/iv/test_absorbing_validation.py b/linearmodels/tests/iv/test_absorbing_validation.py new file mode 100644 index 0000000000..b862297de9 --- /dev/null +++ b/linearmodels/tests/iv/test_absorbing_validation.py @@ -0,0 +1,107 @@ +""" +Validation and messages of AbsorbingLS and the IV models. + +* AbsorbingLS.fit accepts the methods "auto", "hdfe" and "lsmr", whatever their case, + and nothing else. It used LSMR for any other name. +""" + +import numpy as np +from numpy.testing import assert_allclose +import pandas as pd +import pytest + +import linearmodels.iv.absorbing +from linearmodels.iv.absorbing import AbsorbingLS + +NOBS = 200 + +pytestmark = pytest.mark.filterwarnings( + "ignore::linearmodels.shared.exceptions.MissingValueWarning" +) + + +@pytest.fixture(scope="module") +def data(): + rng = np.random.default_rng(0) + x = pd.DataFrame(rng.standard_normal((NOBS, 2)), columns=["x0", "x1"]) + cats = pd.DataFrame( + { + "g0": pd.Categorical(rng.integers(0, 9, NOBS)), + "g1": pd.Categorical(rng.integers(0, 4, NOBS)), + } + ) + y = pd.Series(x.sum(axis=1) + rng.standard_normal(NOBS), name="y") + return y, x, cats + + +@pytest.fixture +def spy_lsmr(monkeypatch): + """Record the calls to the function that absorbs variables using LSMR""" + calls = [] + original = linearmodels.iv.absorbing.lsmr_annihilate + + def spy(*args, **kwargs): + calls.append(kwargs) + return original(*args, **kwargs) + + monkeypatch.setattr(linearmodels.iv.absorbing, "lsmr_annihilate", spy) + return calls + + +@pytest.mark.parametrize( + "method", ["hdfee", "foo", "", "hdf", "lsmr ", None, 1, ["hdfe"]] +) +def test_unknown_method(data, method): + y, x, cats = data + mod = AbsorbingLS(y, x, absorb=cats) + with pytest.raises(ValueError, match=r'one of "auto", "hdfe" or "lsmr"') as info: + mod.fit(method=method) + assert repr(method) in str(info.value) + # The model is not affected by the failed call + assert_allclose(mod.fit().params, mod.fit(method="auto").params) + + +@pytest.mark.parametrize("method", ["auto", "Auto", "AUTO", "hdfe", "HDFE", "Hdfe"]) +def test_hdfe_by_name(data, spy_lsmr, method): + y, x, cats = data + res = AbsorbingLS(y, x, absorb=cats).fit(method=method) + assert not spy_lsmr + expected = AbsorbingLS(y, x, absorb=cats).fit(method="hdfe") + assert_allclose(res.params, expected.params, rtol=1e-12) + + +@pytest.mark.parametrize("method", ["lsmr", "LSMR", "Lsmr"]) +def test_lsmr_by_name(data, spy_lsmr, method): + y, x, cats = data + res = AbsorbingLS(y, x, absorb=cats).fit(method=method) + assert len(spy_lsmr) == 1 + expected = AbsorbingLS(y, x, absorb=cats).fit(method="lsmr") + assert_allclose(res.params, expected.params, rtol=1e-12) + + +def test_hdfe_name_is_checked_against_the_model(data): + # A case that is not the lower case is the same method, and so it is checked in the + # same way: it cannot be used with continuous variables to absorb + y, x, cats = data + absorb = pd.concat([cats, x[["x1"]].rename(columns={"x1": "z"})], axis=1) + with pytest.raises(RuntimeError, match="HDFE has been set"): + AbsorbingLS(y, x[["x0"]], absorb=absorb).fit(method="HDFE") + + +def test_method_with_another_case_does_not_absorb_again(data, monkeypatch): + y, x, cats = data + mod = AbsorbingLS(y, x, absorb=cats) + calls = [] + original = AbsorbingLS._first_time_fit + + def spy(self, *args, **kwargs): + calls.append(args) + return original(self, *args, **kwargs) + + monkeypatch.setattr(AbsorbingLS, "_first_time_fit", spy) + mod.fit(method="HDFE") + mod.fit(method="hdfe") + mod.fit(method="Hdfe", cov_type="unadjusted") + assert len(calls) == 1 + mod.fit(method="LSMR") + assert len(calls) == 2 From 50201702361bad0aa8788650c64bb76d97c9b20e Mon Sep 17 00:00:00 2001 From: Kevin Sheppard Date: Mon, 12 Oct 2026 10:41:26 +0100 Subject: [PATCH 2/6] BUG: Say in the lsmr_options warning that the method is needed The FutureWarning of AbsorbingLS.fit(lsmr_options=...) said "Use absorb_options". For a model that can be absorbed using HDFE, which is a model without weights whose absorbed variables are all categorical, absorb_options are the options of HDFE unless the method is LSMR, so that advice raises a ValueError (#752 made the options of LSMR with HDFE an error). The warning and the deprecation note in the docstring now say to use absorb_options with method="lsmr", which gives the same results as lsmr_options and does not warn. --- linearmodels/iv/absorbing.py | 7 +++--- .../tests/iv/test_absorbing_validation.py | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/linearmodels/iv/absorbing.py b/linearmodels/iv/absorbing.py index 64777ac86b..16a4a5ac23 100644 --- a/linearmodels/iv/absorbing.py +++ b/linearmodels/iv/absorbing.py @@ -1256,8 +1256,8 @@ def fit( .. deprecated:: 4.17 - Use absorb_options to pass options. ``lsmr_options`` will be - removed in linearmodels 8.0. + Use absorb_options with method="lsmr" to pass options. + ``lsmr_options`` will be removed in linearmodels 8.0. cov_config Additional parameters to pass to covariance estimator. The list @@ -1320,7 +1320,8 @@ def fit( raise ValueError("absorb_options cannot be used with lsmr_options") warnings.warn( "lsmr_options is deprecated and will be removed in linearmodels " - "8.0. Use absorb_options.", + '8.0. Use absorb_options with method="lsmr", which passes the options ' + "to scipy.sparse.linalg.lsmr.", FutureWarning, stacklevel=2, ) diff --git a/linearmodels/tests/iv/test_absorbing_validation.py b/linearmodels/tests/iv/test_absorbing_validation.py index b862297de9..affa4ac3e6 100644 --- a/linearmodels/tests/iv/test_absorbing_validation.py +++ b/linearmodels/tests/iv/test_absorbing_validation.py @@ -3,6 +3,8 @@ * AbsorbingLS.fit accepts the methods "auto", "hdfe" and "lsmr", whatever their case, and nothing else. It used LSMR for any other name. +* The FutureWarning of lsmr_options says what to use instead, which needs a method on + models that can use HDFE. """ import numpy as np @@ -105,3 +107,25 @@ def spy(self, *args, **kwargs): assert len(calls) == 1 mod.fit(method="LSMR") assert len(calls) == 2 + + +def test_lsmr_options_warning_says_how_to_migrate(data, spy_lsmr): + y, x, cats = data + options = {"atol": 1e-9, "btol": 1e-9} + with pytest.warns(FutureWarning, match=r'absorb_options with method="lsmr"') as rec: + old = AbsorbingLS(y, x, absorb=cats).fit(lsmr_options=options) + assert len([w for w in rec if issubclass(w.category, FutureWarning)]) == 1 + assert len(spy_lsmr) == 1 + + # The advice of the warning gives the same results without a warning. The model + # can be absorbed using HDFE, and then the options of LSMR need the method. + new = AbsorbingLS(y, x, absorb=cats).fit(method="lsmr", absorb_options=options) + assert_allclose(new.params, old.params, rtol=1e-12) + assert_allclose(new.std_errors, old.std_errors, rtol=1e-12) + with pytest.raises(ValueError, match=r'method="lsmr"'): + AbsorbingLS(y, x, absorb=cats).fit(absorb_options=options) + + +def test_deprecation_note_in_docstring_names_the_method(): + doc = AbsorbingLS.fit.__doc__ + assert 'Use absorb_options with method="lsmr"' in " ".join(doc.split()) From 3c7132a34f4ecc7993215f6636330426c3ae4a29 Mon Sep 17 00:00:00 2001 From: Kevin Sheppard Date: Mon, 12 Oct 2026 10:42:04 +0100 Subject: [PATCH 3/6] BUG: Update the number of observations of an Interaction that drops observations Interaction.drop is public. It removes observations from the instance in place, but nobs kept the number of observations before the call, so an Interaction that had dropped 3 of 50 observations reported 50 although its cat, cont and sparse had 47 rows. AbsorbingLS drops the rows with missing values of its own copies of the interactions in the same way, and so the interactions of a model with missing values reported the number of observations before the rows were dropped. nobs is now the number of observations that remain. drop has a docstring that says that it modifies the instance in place and returns None, and nobs has one too. --- linearmodels/iv/absorbing.py | 17 +++++ .../tests/iv/test_absorbing_validation.py | 65 ++++++++++++++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/linearmodels/iv/absorbing.py b/linearmodels/iv/absorbing.py index 16a4a5ac23..2dae3203b7 100644 --- a/linearmodels/iv/absorbing.py +++ b/linearmodels/iv/absorbing.py @@ -475,6 +475,7 @@ def __init__( @property def nobs(self) -> int: + """Number of observations, which drop reduces""" assert self._nobs is not None return self._nobs @@ -529,8 +530,24 @@ def isnull(self) -> Series: return Series(cat_null.to_numpy() | cont_null.to_numpy(), index=index) def drop(self, locs: linearmodels.typing.data.BoolArray) -> None: + """ + Drop observations + + Parameters + ---------- + locs : ndarray + Boolean array with one element for each observation, which is True + for the observations to drop. + + Notes + ----- + The instance is modified in place and nothing is returned. Afterwards + ``nobs``, ``cat``, ``cont``, ``sparse`` and ``hash`` use only the + remaining observations. + """ self._cat_data.drop(locs) self._cont_data.drop(locs) + self._nobs = self._cat_data.shape[0] @property def sparse(self) -> csc_array: diff --git a/linearmodels/tests/iv/test_absorbing_validation.py b/linearmodels/tests/iv/test_absorbing_validation.py index affa4ac3e6..83072916ed 100644 --- a/linearmodels/tests/iv/test_absorbing_validation.py +++ b/linearmodels/tests/iv/test_absorbing_validation.py @@ -5,6 +5,8 @@ and nothing else. It used LSMR for any other name. * The FutureWarning of lsmr_options says what to use instead, which needs a method on models that can use HDFE. +* Interaction.drop modifies the instance, and nobs must be the number of observations + that remain. """ import numpy as np @@ -13,7 +15,8 @@ import pytest import linearmodels.iv.absorbing -from linearmodels.iv.absorbing import AbsorbingLS +from linearmodels.iv.absorbing import AbsorbingLS, Interaction +from linearmodels.shared.exceptions import MissingValueWarning NOBS = 200 @@ -129,3 +132,63 @@ def test_lsmr_options_warning_says_how_to_migrate(data, spy_lsmr): def test_deprecation_note_in_docstring_names_the_method(): doc = AbsorbingLS.fit.__doc__ assert 'Use absorb_options with method="lsmr"' in " ".join(doc.split()) + + +@pytest.mark.parametrize("kind", ["both", "cat", "cont", "none", "frame"]) +def test_interaction_drop(kind): + rng = np.random.default_rng(1) + cat = pd.DataFrame({"c": pd.Categorical(rng.integers(0, 4, 50))}) + cont = pd.DataFrame(rng.standard_normal((50, 2)), columns=["a", "b"]) + if kind == "both": + inter = Interaction(cat, cont) + elif kind == "cat": + inter = Interaction(cat) + elif kind == "cont": + inter = Interaction(cont=cont) + elif kind == "none": + inter = Interaction(nobs=50) + else: + inter = Interaction.from_frame(pd.concat([cat, cont], axis=1)) + assert inter.nobs == 50 + locs = np.zeros(50, dtype=bool) + locs[[0, 7, 49]] = True + cat_before, cont_before = inter.cat.copy(), inter.cont.copy() + + assert inter.drop(locs) is None + assert inter.nobs == 47 + assert inter.cat.shape[0] == inter.cont.shape[0] == 47 + assert inter.sparse.shape[0] == 47 + assert inter.isnull.shape[0] == 47 + pd.testing.assert_frame_equal(inter.cat, cat_before.loc[~locs]) + pd.testing.assert_frame_equal(inter.cont, cont_before.loc[~locs]) + + # A second call drops from the remaining observations + more = np.zeros(47, dtype=bool) + more[:5] = True + inter.drop(more) + assert inter.nobs == 42 + assert inter.sparse.shape[0] == 42 + + +def test_interaction_drop_is_documented(): + doc = Interaction.drop.__doc__ + assert doc is not None + flat = " ".join(doc.split()) + assert "in place" in flat + assert "nothing is returned" in flat + assert Interaction.nobs.__doc__ + + +def test_nobs_of_the_interactions_of_a_model_with_missing_values(data): + y, x, cats = data + x = x.copy() + x.iloc[[3, 50], 0] = np.nan + cont = pd.DataFrame({"k": np.arange(NOBS, dtype=float)}) + interaction = Interaction(cats[["g0"]], cont) + with pytest.warns(MissingValueWarning): + mod = AbsorbingLS(y, x, absorb=cats, interactions=interaction) + assert mod.dependent.shape[0] == NOBS - 2 + assert mod._absorb_inter.nobs == NOBS - 2 + assert [i.nobs for i in mod._interaction_list] == [NOBS - 2] + # The interaction of the caller is not modified + assert interaction.nobs == NOBS From be5dc4686601f552e88c954fc0a0451e94d8d1a6 Mon Sep 17 00:00:00 2001 From: Kevin Sheppard Date: Mon, 12 Oct 2026 10:42:42 +0100 Subject: [PATCH 4/6] BUG: Do not tell users to use a property that the model does not have The error that clusters have the wrong number of rows is shared by the IV models, AbsorbingLS and the panel covariance estimators. It told users that "the model property `notnull` contains the locations of the observations that have no missing values". The IV models have notnull, but AbsorbingLS has neither notnull nor isnull, and the panel models name the property not_null. The message also said "have have". The message now says what is true of every model: observations with a missing value in the regression variables are dropped when the model is created, so clusters must have one row for each of the remaining observations, and the observations with missing values should be dropped from the data and from clusters before the model is estimated. This is the smaller change. Giving AbsorbingLS a documented notnull and isnull, like the IV models, would still leave the panel models without a property of that name. The first sentence, which the tests match, is unchanged. --- linearmodels/iv/covariance.py | 10 ++-- .../tests/iv/test_absorbing_validation.py | 50 +++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/linearmodels/iv/covariance.py b/linearmodels/iv/covariance.py index 02a2464160..4f8747aeae 100644 --- a/linearmodels/iv/covariance.py +++ b/linearmodels/iv/covariance.py @@ -37,11 +37,11 @@ ] CLUSTER_ERR = """\ -clusters has the wrong nobs. Expected {0}, got {1}. Any missing observation -in the regression variables have have been dropped. When using a clustered -covariance estimator, drop missing data before estimating the model. The model -property `notnull` contains the locations of the observations that have no -missing values.""" +clusters has the wrong nobs. Expected {0}, got {1}. Observations with a missing +value in the regression variables are dropped when the model is created, so +clusters must have one row for each of the remaining observations. When using a +clustered covariance estimator, drop the observations with missing values from +the data and from clusters before estimating the model.""" def kernel_weight_bartlett( diff --git a/linearmodels/tests/iv/test_absorbing_validation.py b/linearmodels/tests/iv/test_absorbing_validation.py index 83072916ed..5f726c3d06 100644 --- a/linearmodels/tests/iv/test_absorbing_validation.py +++ b/linearmodels/tests/iv/test_absorbing_validation.py @@ -7,15 +7,20 @@ models that can use HDFE. * Interaction.drop modifies the instance, and nobs must be the number of observations that remain. +* The message about the length of clusters names only what the models have. """ +import re + import numpy as np from numpy.testing import assert_allclose import pandas as pd import pytest +from linearmodels.iv import IV2SLS, IVLIML import linearmodels.iv.absorbing from linearmodels.iv.absorbing import AbsorbingLS, Interaction +from linearmodels.iv.covariance import CLUSTER_ERR from linearmodels.shared.exceptions import MissingValueWarning NOBS = 200 @@ -192,3 +197,48 @@ def test_nobs_of_the_interactions_of_a_model_with_missing_values(data): assert [i.nobs for i in mod._interaction_list] == [NOBS - 2] # The interaction of the caller is not modified assert interaction.nobs == NOBS + + +def test_cluster_message_names_only_what_the_models_have(data): + y, x, cats = data + y = y.copy() + y.iloc[3] = np.nan + clusters = np.arange(NOBS) % 7 # One row for each observation before the drop + z = pd.DataFrame( + np.random.default_rng(2).standard_normal((NOBS, 2)), columns=["z0", "z1"] + ) + endog = (z.sum(axis=1) + x["x0"]).to_frame("e") + models = { + "IV2SLS": IV2SLS(y, x, endog, z), + "IVLIML": IVLIML(y, x, endog, z), + "AbsorbingLS": AbsorbingLS(y, x, absorb=cats), + } + for name, mod in models.items(): + with pytest.raises(ValueError, match="clusters has the wrong nobs") as info: + mod.fit(cov_type="clustered", clusters=clusters) + message = str(info.value) + assert f"Expected {NOBS - 1}, got {NOBS}." in message + assert "have have" not in message + assert "missing" in message + for attribute in re.findall(r"`([^`]+)`", message): + assert hasattr(mod, attribute), f"{name} does not have {attribute}" + # The shared text does not name any attribute of any model + assert "`" not in CLUSTER_ERR + + +def test_clusters_that_are_dropped_with_the_data_are_accepted(data): + # The advice of the message works + y, x, cats = data + y = y.copy() + y.iloc[3] = np.nan + clusters = np.arange(NOBS) % 7 + keep = np.ones(NOBS, dtype=bool) + keep[3] = False + mod = AbsorbingLS(y[keep], x[keep], absorb=cats[keep]) + res = mod.fit(cov_type="clustered", clusters=clusters[keep]) + assert res.nobs == NOBS - 1 + # The rows are the rows of the model that drops them + auto = AbsorbingLS(y, x, absorb=cats) + assert_allclose( + auto.fit(cov_type="clustered", clusters=clusters[keep]).params, res.params + ) From 7fd480cbde4b87e36781a2b8ba0a042313cc8723 Mon Sep 17 00:00:00 2001 From: Kevin Sheppard Date: Mon, 12 Oct 2026 10:43:13 +0100 Subject: [PATCH 5/6] BUG: Fix the grammar of the error when IVLIML cannot estimate kappa The ValueError of IVLIML.fit when kappa cannot be estimated said "This is most likely occurs if the instrument matrix is rank deficient". It now says "This most likely occurs if the instrument matrix is rank deficient". The error of the computation of kappa that follows it is unchanged. --- linearmodels/iv/model.py | 2 +- .../tests/iv/test_absorbing_validation.py | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/linearmodels/iv/model.py b/linearmodels/iv/model.py index 4eecedeab3..6e79234dac 100644 --- a/linearmodels/iv/model.py +++ b/linearmodels/iv/model.py @@ -709,7 +709,7 @@ def fit( liml_kappa = nan if kappa is None: raise ValueError( - "Unable to estimate kappa. This is most likely occurs if the " + "Unable to estimate kappa. This most likely occurs if the " f"instrument matrix is rank deficient. The error raised when " f"computing kappa was:\n\n{exc}" ) from exc diff --git a/linearmodels/tests/iv/test_absorbing_validation.py b/linearmodels/tests/iv/test_absorbing_validation.py index 5f726c3d06..ee9d32b0b7 100644 --- a/linearmodels/tests/iv/test_absorbing_validation.py +++ b/linearmodels/tests/iv/test_absorbing_validation.py @@ -8,6 +8,7 @@ * Interaction.drop modifies the instance, and nobs must be the number of observations that remain. * The message about the length of clusters names only what the models have. +* The message about the estimation of kappa is grammatical. """ import re @@ -21,6 +22,7 @@ import linearmodels.iv.absorbing from linearmodels.iv.absorbing import AbsorbingLS, Interaction from linearmodels.iv.covariance import CLUSTER_ERR +from linearmodels.iv.model import _IVModelBase from linearmodels.shared.exceptions import MissingValueWarning NOBS = 200 @@ -242,3 +244,23 @@ def test_clusters_that_are_dropped_with_the_data_are_accepted(data): assert_allclose( auto.fit(cov_type="clustered", clusters=clusters[keep]).params, res.params ) + + +def test_liml_kappa_message(data, monkeypatch): + y, x, _ = data + rng = np.random.default_rng(3) + z = pd.DataFrame(rng.standard_normal((NOBS, 2)), columns=["z0", "z1"]) + endog = (z.sum(axis=1) + rng.standard_normal(NOBS)).to_frame("e") + + def fail(self): + raise np.linalg.LinAlgError("Eigenvalues did not converge") + + monkeypatch.setattr(_IVModelBase, "_estimate_kappa", fail) + with pytest.raises(ValueError, match="Unable to estimate kappa") as info: + IVLIML(y, x, endog, z).fit() + message = str(info.value) + assert ( + "This most likely occurs if the instrument matrix is rank deficient." in message + ) + assert "This is most likely" not in message + assert "Eigenvalues did not converge" in message From 53a69ea95e035111a507e8871ee2266ec105103c Mon Sep 17 00:00:00 2001 From: Kevin Sheppard Date: Mon, 12 Oct 2026 10:43:44 +0100 Subject: [PATCH 6/6] DOC: End the summaries of Interaction.drop and Interaction.nobs with a period The numpydoc lint of the documentation conventions requires it. --- linearmodels/iv/absorbing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/linearmodels/iv/absorbing.py b/linearmodels/iv/absorbing.py index 2dae3203b7..bd711b2f92 100644 --- a/linearmodels/iv/absorbing.py +++ b/linearmodels/iv/absorbing.py @@ -475,7 +475,7 @@ def __init__( @property def nobs(self) -> int: - """Number of observations, which drop reduces""" + """Number of observations, which drop reduces.""" assert self._nobs is not None return self._nobs @@ -531,7 +531,7 @@ def isnull(self) -> Series: def drop(self, locs: linearmodels.typing.data.BoolArray) -> None: """ - Drop observations + Drop observations. Parameters ----------