Repository: statsmodels/statsmodels

Issue:
Save the offset name in `GLM` and results wrapper
#### Is your feature request related to a problem? Please describe
Post model training, it is helpful to know which variable was used as the offset. This aids in post model analysis and deployment.

The offset array is saved and can be accessed after saving the model, but the name of the offset variable is lost when it is a pandas series. The series is [converted to a np.array](https://github.com/statsmodels/statsmodels/blob/ab10165eb897729b50e703b4ea831ae712b53585/statsmodels/genmod/generalized_linear_model.py#L315-L316) which removed the name. Current state, it is difficult to tell which variable may have been used as an offset without tracking it outside the model.

Example use case: Sharing a saved model with a peer. They inspect it to determine what variable was used as the offset in training.

The same may apply to the `var_weights` and `freq_weights` for GLM.

#### Describe the solution you'd like
The model has access on `__init__` to the name of the offset if it is a pandas series. A way to save the offset array's name if it is a series would be wonderful.

Similar to how the endog and exog names can be used in the model summary.

Here's a few ideas I had for how to implement this. Happy to hear if there's a better option.

1. Add an `offset_name` property for GLM
    - Similar to the base models [`endog_names`/`exog_names`](https://github.com/statsmodels/statsmodels/blob/ab10165eb897729b50e703b4ea831ae712b53585/statsmodels/base/model.py#L235-L247)
    - Simple to implement
2. Add it to the `model.data` so it's handled by [`PandasData`](https://github.com/statsmodels/statsmodels/blob/ab10165eb897729b50e703b4ea831ae712b53585/statsmodels/base/data.py#L498)
    - The name could be added back to the offset when making the results wrapper (at least I think that's how it works)
    - I could use some guidance on how to implement this if it is the preferred approach
    - I think it has something to do with the data attrs but it's a bit hard to track down
3. Do not convert to a numpy array if it is a series
    - One could use `model.offset.name` to get at the variable name 
    - Doesn't line up with how the rest of the code works, it expects numpy arrays
    - Likely not a good option
4. User adds `offset_name` attribute to the model class before saving it.
    - Seems like a bad idea, would like support in statsmodels

#### Describe alternatives you have considered
Current workaround is saving the offset name in a separate file, which is not ideal.

#### Additional context
Happy to work on a PR for this.


Discussion:
josef-pkt: Thanks, looks good

However, we need a default name if offset or similar are used, i.e. name should be a string and not None.
The name attribute is None should be for the case when the corresponding variable, offset, ..., was not used.

one possibility
add a extra argument to the helper function with the default name, e.g. "offset" or "exposure".
That way the helper function would still be generic, and we don't need to fix up each returned None name.

We want to be able to add a string to the summary table

Patch:
diff --git a/statsmodels/genmod/generalized_linear_model.py b/statsmodels/genmod/generalized_linear_model.py
index 4617ad4bc..6cd29fd76 100644
--- a/statsmodels/genmod/generalized_linear_model.py
+++ b/statsmodels/genmod/generalized_linear_model.py
@@ -41,6 +41,7 @@ from statsmodels.tools.decorators import (
     cached_data,
     cached_value,
 )
+from statsmodels.tools.data import _as_array_with_name
 from statsmodels.tools.docstring import Docstring
 from statsmodels.tools.sm_exceptions import (
     DomainWarning,
@@ -307,15 +308,21 @@ class GLM(base.LikelihoodModel):
                            f"{type(family).__name__} family."),
                           DomainWarning)
 
+        self._exposure_name = None
+        self._offset_name = None
+        self._freq_weights_name = None
+        self._var_weights_name = None
+
         if exposure is not None:
-            exposure = np.log(exposure)
+            exposure_array, self._exposure_name = _as_array_with_name(exposure, "exposure")
+            exposure = np.log(exposure_array)
         if offset is not None:  # this should probably be done upstream
-            offset = np.asarray(offset)
+            offset, self._offset_name = _as_array_with_name(offset, "offset")
 
         if freq_weights is not None:
-            freq_weights = np.asarray(freq_weights)
+            freq_weights, self._freq_weights_name = _as_array_with_name(freq_weights, "freq_weights")
         if var_weights is not None:
-            var_weights = np.asarray(var_weights)
+            var_weights, self._var_weights_name = _as_array_with_name(var_weights, "var_weights")
 
         self.freq_weights = freq_weights
         self.var_weights = var_weights
@@ -1558,6 +1565,39 @@ class GLM(base.LikelihoodModel):
         res._results.results_constrained = res_constr
         return res
 
+    @property
+    def offset_name(self):
+        """
+        Name of the offset variable if available. If offset is not a pd.Series,
+        defaults to 'offset'.
+        """
+        return self._offset_name
+
+    @property
+    def exposure_name(self):
+        """
+        Name of the exposure variable if available. If exposure is not a pd.Series,
+        defaults to 'exposure'.
+        """
+        return self._exposure_name
+
+    @property
+    def freq_weights_name(self):
+        """
+        Name of the freq weights variable if available. If freq_weights is not a
+        pd.Series, defaults to 'freq_weights'.
+        """
+        return self._freq_weights_name
+
+    @property
+    def var_weights_name(self):
+        """
+        Name of var weights variable if available. If var_weights is not a pd.Series,
+        defaults to 'var_weights'.
+
+        """
+        return self._var_weights_name
+
 
 get_prediction_doc = Docstring(pred.get_prediction_glm.__doc__)
 get_prediction_doc.remove_parameters("pred_kwds")
diff --git a/statsmodels/tools/data.py b/statsmodels/tools/data.py
index 115bc1699..9e507030c 100644
--- a/statsmodels/tools/data.py
+++ b/statsmodels/tools/data.py
@@ -19,7 +19,8 @@ def _check_period_index(x, freq="M"):
     if not inferred_freq.startswith(freq):
         raise ValueError("Expected frequency {}. Got {}".format(freq,
                                                                 inferred_freq))
-
+def is_series(obj):
+    return isinstance(obj, pd.Series)
 
 def is_data_frame(obj):
     return isinstance(obj, pd.DataFrame)
@@ -121,3 +122,24 @@ def _is_recarray(data):
         return isinstance(data, np.core.recarray)
     else:
         return isinstance(data, np.rec.recarray)
+
+def _as_array_with_name(obj, default_name):
+    """
+    Call np.asarray() on obj and attempt to get the name if its a Series.
+
+    Parameters
+    ----------
+    obj: pd.Series
+        Series to convert to an array
+    default_name: str
+        The default name to return in case the object isn't a pd.Series or has
+        no name attribute.
+
+    Returns
+    -------
+    array_and_name: tuple[np.ndarray, str]
+        The data casted to np.ndarra and the series name or None
+    """
+    if is_series(obj):
+        return (np.asarray(obj), obj.name)
+    return (np.asarray(obj), default_name)


Test patch:
diff --git a/statsmodels/genmod/tests/test_glm.py b/statsmodels/genmod/tests/test_glm.py
index fd169d36f..b3b6876a4 100644
--- a/statsmodels/genmod/tests/test_glm.py
+++ b/statsmodels/genmod/tests/test_glm.py
@@ -2661,3 +2661,62 @@ def test_tweedie_score():
             nhess = approx_hess_cs(pa, lambda x: model.loglike(x, scale=1))
             ahess = model.hessian(pa, scale=1)
             assert_allclose(nhess, ahess, atol=5e-8, rtol=5e-8)
+
+def test_names():
+    """Test the name properties if using a pandas series.
+
+    They should not be the defaults if the series has a name.
+
+    Don't care about the data here, only testing the name properties.
+    """
+    y = pd.Series([0, 1], name="endog_not_default")
+    x = pd.DataFrame({"a": [1, 1], "b": [1, 0]})
+    exposure = pd.Series([0, 0], name="exposure_not_default")
+    freq_weights = pd.Series([0, 0], name="freq_weights_not_default")
+    offset = pd.Series([0, 0], name="offset_not_default")
+    var_weights = pd.Series([0, 0], name="var_weights_not_default")
+
+    model = GLM(
+        endog=y,
+        exog=x,
+        exposure=exposure,
+        freq_weights=freq_weights,
+        offset=offset,
+        var_weights=var_weights,
+        family=sm.families.Tweedie(),
+    )
+    assert model.offset_name == "offset_not_default"
+    assert model.exposure_name == "exposure_not_default"
+    assert model.freq_weights_name == "freq_weights_not_default"
+    assert model.var_weights_name == "var_weights_not_default"
+    assert model.endog_names == "endog_not_default"
+    assert model.exog_names == ["a", "b"]
+
+
+def test_names_default():
+    """Test the name properties if using a numpy arrays.
+
+    Don't care about the data here, only testing the name properties.
+    """
+    y = np.array([0, 1])
+    x = np.array([[1, 1,], [1, 0]])
+    exposure = np.array([0, 0])
+    freq_weights = np.array([0, 0])
+    offset = np.array([0, 0])
+    var_weights = np.array([0, 0])
+
+    model = GLM(
+        endog=y,
+        exog=x,
+        exposure=exposure,
+        freq_weights=freq_weights,
+        offset=offset,
+        var_weights=var_weights,
+        family=sm.families.Tweedie(),
+    )
+    assert model.offset_name == "offset"
+    assert model.exposure_name == "exposure"
+    assert model.freq_weights_name == "freq_weights"
+    assert model.var_weights_name == "var_weights"
+    assert model.endog_names == "y"
+    assert model.exog_names == ["const", "x1"]
diff --git a/statsmodels/tools/tests/test_data.py b/statsmodels/tools/tests/test_data.py
index 178004aa1..21a4f7f8e 100644
--- a/statsmodels/tools/tests/test_data.py
+++ b/statsmodels/tools/tests/test_data.py
@@ -33,3 +33,16 @@ def test_patsy_577():
     np.testing.assert_(data._is_using_patsy(endog, None))
     exog = dmatrix("var2 - 1", df)
     np.testing.assert_(data._is_using_patsy(endog, exog))
+
+
+def test_as_array_with_name_series():
+    s = pandas.Series([1], name="hello")
+    arr, name = data._as_array_with_name(s, "not_used")
+    np.testing.assert_array_equal(np.array([1]), arr)
+    assert name == "hello"
+
+
+def test_as_array_with_name_array():
+    arr, name = data._as_array_with_name(np.array([1]), "default")
+    np.testing.assert_array_equal(np.array([1]), arr)
+    assert name == "default"