Repository: statsmodels/statsmodels

Issue:
ENH: Add optional parameters for summary_col to indicate FEs
Researchers tend to show they used categorical variables without all the coefficients, saying whether they included a fixed effect. This PR adds that feature.

### Example
```python
import numpy as np
import pandas as pd
import seaborn as sns
import statsmodels.formula.api as smf
from statsmodels.iolib.summary2 import summary_col

# Load the diamonds dataset
diamonds = sns.load_dataset('diamonds')

# Adapted regressions for the diamonds dataset
regressions = [
    (smf.ols('np.log(price) ~ carat', data=diamonds).fit(), 'log(Price) ~ Carat'),
    (smf.ols('np.log(price) ~ np.log(carat)', data=diamonds).fit(), 'log(Price) ~ log(Carat)'),
    (smf.ols('np.log(price) ~ C(cut)', data=diamonds).fit(), 'log(Price) ~ C(Cut)'),
    (smf.ols('np.log(price) ~ C(clarity)', data=diamonds).fit(), 'log(Price) ~ C(Clarity)'),
    (smf.ols('np.log(price) ~ carat + C(cut) + C(clarity)', data=diamonds).fit(), 'log(Price) ~ Carat + C(Cut) + C(Clarity)')
]

# Output summary of all regressions
summary = summary_col([reg[0] for reg in regressions],
                      model_names=[f'{i}. '+reg[1] for i, reg in enumerate(regressions, 1)]
                    )

summary
```

<img width="971" alt="Screenshot 2024-04-02 at 9 50 22 PM" src="https://github.com/statsmodels/statsmodels/assets/72167238/6b075616-c711-4fc2-a3fe-ccd0cf943ef2">


With more complicated regressions or broader categories, this can quickly overwhelm the summary.

Now, with amended function.

```
"""
fixed_effects : list[str], optional
        List of categorical variables for which to indicate presence of fixed effects.
fe_present : str, optional
    String to indicate the presence of fixed effects. Default is "Yes".
fe_absent : str, optional
    String to indicate the absence of fixed effects. Default is empty string.
"""

summary = summary_col([reg[0] for reg in regressions],
                      model_names=[f'{i}. '+reg[1] for i, reg in enumerate(regressions, 1)],
                      fixed_effects=['cut', 'clarity'],
                    )
```

<img width="997" alt="Screenshot 2024-04-02 at 9 50 30 PM" src="https://github.com/statsmodels/statsmodels/assets/72167238/2ad0dec7-f678-418b-926f-de3708002966">


Much more concise.


Discussion:

Resolving patch:
diff --git a/statsmodels/iolib/summary2.py b/statsmodels/iolib/summary2.py
--- a/statsmodels/iolib/summary2.py
+++ b/statsmodels/iolib/summary2.py
@@ -471,7 +471,8 @@ def _make_unique(list_of_names):
 
 def summary_col(results, float_format='%.4f', model_names=(), stars=False,
                 info_dict=None, regressor_order=(), drop_omitted=False,
-                include_r2=True):
+                include_r2=True, fixed_effects=None, fe_present='Yes',
+                fe_absent=''):
     """
     Summarize multiple results instances side-by-side (coefs and SEs)
 
@@ -504,6 +505,14 @@ def summary_col(results, float_format='%.4f', model_names=(), stars=False,
         If True, only regressors in regressor_order will be included.
     include_r2 : bool, optional
         Includes R2 and adjusted R2 in the summary table.
+    fixed_effects : list[str], optional
+        List of categorical variables for which to indicate presence of
+        fixed effects.
+    fe_present : str, optional
+        String to indicate the presence of fixed effects. Default is "Yes".
+    fe_absent : str, optional
+        String to indicate the absence of fixed effects. Default is empty
+        string.
     """
 
     if not isinstance(results, list):
@@ -562,6 +571,19 @@ def merg(x, y):
             idx.append(index[i + 1])
     summ.index = idx
 
+    # add fixed effects info
+    if fixed_effects:
+        if not info_dict:
+            info_dict = {}
+
+        for fe in fixed_effects:
+            info_dict[fe + ' FE'] = (
+                lambda x, fe=fe, fe_present=fe_present, fe_absent=fe_absent:
+                    fe_present
+                    if any((f'C({fe})' in param) for param in x.params.index)
+                    else fe_absent
+                )
+
     # add infos about the models.
     if info_dict:
         cols = [_col_info(x, info_dict.get(x.model.__class__.__name__,
@@ -581,6 +603,21 @@ def merg(x, y):
 
     summ = summ.fillna('')
 
+    # fixed effects processing
+    if fixed_effects:
+        index_series = pd.Series(summ.index, index=summ.index)
+        skip_flag = index_series.apply(
+            lambda x: any((f'C({fe})' in x) for fe in fixed_effects)
+            )
+        skip_next_flag = skip_flag.shift(fill_value=False)
+        final_skip = skip_flag | skip_next_flag
+        summ = summ[~final_skip]
+
+        r_squared_rows = summ.index[summ.index.str.contains('R-squared')]
+        r_squared_section = summ.loc[r_squared_rows]
+        summ = summ.drop(index=r_squared_rows)
+        summ = pd.concat([summ, r_squared_section])
+
     smry = Summary()
     smry._merge_latex = True
     smry.add_df(summ, header=True, align='l')


Test patch:
diff --git a/statsmodels/iolib/tests/test_summary2.py b/statsmodels/iolib/tests/test_summary2.py
--- a/statsmodels/iolib/tests/test_summary2.py
+++ b/statsmodels/iolib/tests/test_summary2.py
@@ -168,6 +168,72 @@ def test_OLSsummary(self):
         result = string_to_find in actual
         assert (result is True)
 
+    def test_summarycol_fixed_effects(self):
+        desired1 = r"""
+======================================
+               model_0 model_1 model_2
+--------------------------------------
+Intercept      1.35    1.32    1.48   
+               (0.19)  (0.42)  (0.73) 
+yrs_married    -0.03   -0.02   -0.02  
+               (0.00)  (0.00)  (0.00) 
+educ           -0.03   -0.02   -0.02  
+               (0.01)  (0.02)  (0.02) 
+occupation FE          Yes     Yes    
+religious FE           Yes     Yes    
+R-squared      0.01    0.02    0.03   
+R-squared Adj. 0.01    0.02    0.02   
+======================================
+Standard errors in parentheses."""  # noqa:W291
+
+        desired2 = r"""
+========================================
+                     mod0   mod1   mod2 
+----------------------------------------
+Intercept           1.35   1.32   1.48  
+                    (0.19) (0.42) (0.73)
+yrs_married         -0.03  -0.02  -0.02 
+                    (0.00) (0.00) (0.00)
+educ                -0.03  -0.02  -0.02 
+                    (0.01) (0.02) (0.02)
+C(religious)[T.2.0]        -0.46  -0.86 
+                           (0.08) (0.87)
+C(religious)[T.3.0]        -0.66  -0.71 
+                           (0.08) (1.13)
+C(religious)[T.4.0]        -0.91  -0.92 
+                           (0.11) (1.03)
+occupation FE              Yes    Yes   
+R-squared           0.01   0.02   0.03  
+R-squared Adj.      0.01   0.02   0.02  
+========================================
+Standard errors in parentheses."""  # noqa:W291
+
+        from statsmodels.datasets.fair import load_pandas
+        df_fair = load_pandas().data
+
+        res0 = OLS.from_formula("affairs ~ yrs_married + educ", df_fair).fit()
+        form1 = "affairs ~ yrs_married + educ + C(occupation) + C(religious)"
+        res1 = OLS.from_formula(form1, df_fair).fit()
+        form2 = "affairs ~ yrs_married + educ + C(occupation) : C(religious)"
+        res2 = OLS.from_formula(form2, df_fair).fit()
+
+        regressions = [res0, res1, res2]
+        summary1 = summary_col(
+            regressions,
+            model_names=[f'model_{i}' for i, _ in enumerate(regressions)],
+            fixed_effects=['occupation', "religious"],
+            float_format='%0.2f',)
+
+        assert_equal(summary1.as_text(), desired1)
+
+        summary2 = summary_col(
+            regressions,
+            model_names=[f'mod{i}' for i, _ in enumerate(regressions)],
+            fixed_effects=['occupation'],
+            float_format='%0.2f',)
+
+        assert_equal(summary2.as_text(), desired2)
+
 
 def test_ols_summary_rsquared_label():
     # Check that the "uncentered" label is correctly added after rsquared