PyProcessMacro 1.0.13__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,8 @@
3
3
  Core module for the PyProcess Macro.
4
4
  """
5
5
  from .process import Process
6
+ from .utils import ConvergenceError
6
7
 
7
- __all__ = ["Process"]
8
+ __all__ = ["Process", "ConvergenceError"]
8
9
 
9
- __version__ = "1.0.13"
10
+ __version__ = "2.0.0"
pyprocessmacro/models.py CHANGED
@@ -16,11 +16,32 @@ from .utils import (
16
16
  eval_expression,
17
17
  bias_corrected_ci,
18
18
  z_score,
19
+ t_score,
19
20
  percentile_ci,
20
21
  find_significance_region,
22
+ ConvergenceError,
21
23
  )
22
24
 
23
25
 
26
+ def _coerce_numeric(df):
27
+ """
28
+ Convert the columns of a summary table that hold numbers stored as strings back to numbers.
29
+
30
+ The tables are assembled with np.concatenate, which turns every statistic into a string as soon as a
31
+ column of names sits next to it. pd.to_numeric(errors="ignore") used to undo that but was removed in
32
+ pandas 3, so this reproduces its behaviour: convert a column when every entry parses as a number, and
33
+ leave it untouched otherwise.
34
+ """
35
+
36
+ def convert(column):
37
+ try:
38
+ return pd.to_numeric(column)
39
+ except (ValueError, TypeError):
40
+ return column
41
+
42
+ return df.apply(convert)
43
+
44
+
24
45
  class BaseLogit(object):
25
46
  """
26
47
  A convenience parent class for the methods used in Logistic models.
@@ -84,14 +105,26 @@ class BaseLogit(object):
84
105
  return -self._hessian(params) / self._n_obs
85
106
 
86
107
  oldparams = np.inf
87
- newparams = np.repeat(0, self._n_vars)
108
+ newparams = np.zeros(self._n_vars)
88
109
  while iterations < max_iter and np.any(
89
110
  np.abs(newparams - oldparams) > tolerance
90
111
  ):
91
112
  oldparams = newparams
92
- H = hess(oldparams)
93
- newparams = oldparams - dot(inv(H), score(oldparams))
113
+ try:
114
+ H = hess(oldparams)
115
+ newparams = oldparams - dot(inv(H), score(oldparams))
116
+ except LinAlgError:
117
+ raise ConvergenceError("The Hessian of the logistic regression is singular.")
94
118
  iterations += 1
119
+ if not np.all(np.isfinite(newparams)):
120
+ raise ConvergenceError(
121
+ "The logistic regression diverged (the outcome may be perfectly separated)."
122
+ )
123
+ if np.any(np.abs(newparams - oldparams) > tolerance):
124
+ raise ConvergenceError(
125
+ f"The logistic regression did not converge in {max_iter} iterations "
126
+ "(increase 'iterate', relax 'convergence', or check the outcome for separation)."
127
+ )
95
128
  return newparams
96
129
 
97
130
 
@@ -267,7 +300,7 @@ class OLSOutcomeModel(BaseOutcomeModel):
267
300
  resid = y - dot(x, betas)
268
301
  mse = (resid ** 2).sum() / df_e
269
302
  sse = dot(resid.T, resid) / df_e
270
- errortype = "standard" if self._options["hc3"] is False else "HC3"
303
+ errortype = self._options.get("cov_type") or ("HC3" if self._options.get("hc3") else "standard")
271
304
  if errortype == "standard":
272
305
  vcv = np.true_divide(1, n_obs - n_vars) * dot(resid.T, resid) * inv_xx
273
306
  elif errortype == "HC0":
@@ -275,7 +308,7 @@ class OLSOutcomeModel(BaseOutcomeModel):
275
308
  vcv = dot(dot(dot(inv_xx, x.T) * sq_resid, x), inv_xx)
276
309
  elif errortype == "HC1":
277
310
  sq_resid = (resid ** 2).squeeze()
278
- vcv = np.true_divide(n_obs, n_obs - n_vars - 1) * dot(
311
+ vcv = np.true_divide(n_obs, n_obs - n_vars) * dot( # n_vars counts the constant (#52)
279
312
  dot(dot(inv_xx, x.T) * sq_resid, x), inv_xx
280
313
  )
281
314
  elif errortype == "HC2":
@@ -297,13 +330,13 @@ class OLSOutcomeModel(BaseOutcomeModel):
297
330
  t = betas / se
298
331
  p = stats.t.sf(np.abs(t), df_e) * 2
299
332
  conf = self._options["conf"]
300
- zscore = z_score(conf)
333
+ tcrit = t_score(conf, df_e) # OLS intervals use the t distribution, as PROCESS does (#40)
301
334
  R2 = 1 - resid.var() / y.var()
302
- adjR2 = 1 - (1 - R2) * ((n_obs - 1) / (n_obs - n_vars - 1))
335
+ adjR2 = 1 - (1 - R2) * ((n_obs - 1) / df_e) # n_vars already counts the constant (#41)
303
336
  F = (R2 / df_r) / ((1 - R2) / df_e)
304
- F_pval = 1 - stats.f.cdf(F, df_r, df_e)
305
- llci = betas - (se * zscore)
306
- ulci = betas + (se * zscore)
337
+ F_pval = stats.f.sf(F, df_r, df_e)
338
+ llci = betas - (se * tcrit)
339
+ ulci = betas + (se * tcrit)
307
340
  names = [self._symb_to_var.get(x, x) for x in self._exogvars]
308
341
  estimation_results = {
309
342
  "betas": betas,
@@ -399,19 +432,19 @@ class LogitOutcomeModel(BaseOutcomeModel, BaseLogit):
399
432
 
400
433
  # GOF statistics
401
434
  llmodel = self._loglike(betas)
402
- lmodel = np.exp(llmodel)
403
435
  minus2ll = -2 * llmodel
404
436
 
405
437
  null_model = NullLogitModel(self._endog, self._options)
406
438
  betas_null = null_model._optimize()
407
439
  llnull = null_model._loglike(betas_null)
408
- lnull = np.exp(llnull)
409
440
 
410
441
  d = 2 * (llmodel - llnull)
411
442
  pvalue = stats.chi2.sf(d, self._n_vars - 1)
412
443
  mcfadden = 1 - llmodel / llnull
413
- coxsnell = 1 - (lnull / lmodel) ** (2 / self._n_obs)
414
- nagelkerke = coxsnell / (1 - lnull ** (2 / self._n_obs))
444
+ # Likelihood ratios are taken in log space: exp(llnull) underflows to 0 beyond about a
445
+ # thousand observations, which turned both pseudo R-squared into NaN (#42).
446
+ coxsnell = 1 - np.exp(2 * (llnull - llmodel) / self._n_obs)
447
+ nagelkerke = coxsnell / (1 - np.exp(2 * llnull / self._n_obs))
415
448
  names = [self._symb_to_var.get(x, x) for x in self._exogvars]
416
449
  estimation_results = {
417
450
  "betas": betas,
@@ -631,6 +664,7 @@ class ParallelMediationModel(object):
631
664
  boot_betas_y = np.empty((n_boots, len(self._exog_terms_y)))
632
665
  boot_betas_m = np.empty((self._n_meds, n_boots, len(self._exog_terms_m)))
633
666
  n_fail_samples = 0
667
+ max_failures = n_boots # give up once more resamples failed than were requested (#49)
634
668
  boot_ind = 0
635
669
  sampler = bootstrap_sampler(self._n_obs, seed)
636
670
  while boot_ind < n_boots:
@@ -647,8 +681,14 @@ class ParallelMediationModel(object):
647
681
  m_b = self._compute_betas_m(m_e, m_x)
648
682
  boot_betas_m[j][boot_ind] = m_b
649
683
  boot_ind += 1
650
- except LinAlgError: # Hessian (Logit) or X'X (OLS) cannot be inverted
684
+ except (LinAlgError, ConvergenceError): # X'X or the Hessian is singular, or the logit diverged
651
685
  n_fail_samples += 1
686
+ if n_fail_samples > max_failures:
687
+ raise RuntimeError(
688
+ f"{n_fail_samples} bootstrap samples failed to estimate before {n_boots} succeeded. "
689
+ "The model is probably not estimable on resamples of this data (check for separation, "
690
+ "collinearity, or a very small sample)."
691
+ )
652
692
 
653
693
  return boot_betas_y, boot_betas_m, n_fail_samples
654
694
 
@@ -1184,8 +1224,7 @@ class ParallelMediationModel(object):
1184
1224
  rows = np.concatenate([rows_levels, rows_stats], axis=1)
1185
1225
  cols = cols_levels + cols_stats
1186
1226
  df = pd.DataFrame(rows, columns=cols, index=[""] * rows.shape[0])
1187
- # noinspection PyTypeChecker
1188
- return df.apply(lambda s: pd.to_numeric(s, errors='coerce').fillna(s))
1227
+ return _coerce_numeric(df)
1189
1228
 
1190
1229
  def _simple_ind_effects_wrapper(self):
1191
1230
  """
@@ -1218,8 +1257,7 @@ class ParallelMediationModel(object):
1218
1257
  rows = np.concatenate([rows_levels, rows_stats], axis=1)
1219
1258
  cols = ["", "Effect", "Boot SE", "BootLLCI", "BootULCI"]
1220
1259
  df = pd.DataFrame(rows, columns=cols, index=[""] * rows.shape[0])
1221
- # noinspection PyTypeChecker
1222
- return df.apply(lambda s: pd.to_numeric(s, errors='coerce').fillna(s))
1260
+ return _coerce_numeric(df)
1223
1261
 
1224
1262
  def _MM_index_wrapper(self):
1225
1263
  """
@@ -1248,8 +1286,7 @@ class ParallelMediationModel(object):
1248
1286
  rows = np.concatenate([rows_levels, rows_stats], axis=1)
1249
1287
  cols = cols_levels + cols_stats
1250
1288
  df = pd.DataFrame(rows, columns=cols, index=[""] * rows.shape[0])
1251
- # noinspection PyTypeChecker
1252
- return df.apply(pd.to_numeric, args=["ignore"])
1289
+ return _coerce_numeric(df)
1253
1290
 
1254
1291
  def _PMM_index_wrapper(self):
1255
1292
  """
@@ -1278,8 +1315,7 @@ class ParallelMediationModel(object):
1278
1315
  rows = np.concatenate([rows_levels, rows_stats], axis=1)
1279
1316
  cols = cols_levels + cols_stats
1280
1317
  df = pd.DataFrame(rows, columns=cols, index=[""] * rows.shape[0])
1281
- # noinspection PyTypeChecker
1282
- return df.apply(pd.to_numeric, args=["ignore"])
1318
+ return _coerce_numeric(df)
1283
1319
 
1284
1320
  def _CMM_index_wrapper(self):
1285
1321
  """
@@ -1328,8 +1364,7 @@ class ParallelMediationModel(object):
1328
1364
  cols = cols_levels + cols_stats
1329
1365
  df = pd.DataFrame(rows, columns=cols, index=[""] * rows.shape[0])
1330
1366
 
1331
- # noinspection PyTypeChecker
1332
- return df.apply(pd.to_numeric, args=["ignore"])
1367
+ return _coerce_numeric(df)
1333
1368
 
1334
1369
  def _MMM_index_wrapper(self):
1335
1370
  """
@@ -1356,8 +1391,7 @@ class ParallelMediationModel(object):
1356
1391
  rows = np.concatenate([rows_levels, rows_stats], axis=1)
1357
1392
  cols = cols_levels + cols_stats
1358
1393
  df = pd.DataFrame(rows, columns=cols, index=[""] * rows.shape[0])
1359
- # noinspection PyTypeChecker
1360
- return df.apply(pd.to_numeric, args=["ignore"])
1394
+ return _coerce_numeric(df)
1361
1395
 
1362
1396
  def MM_index_summary(self):
1363
1397
  if "MM" in self._analysis_list:
@@ -1572,9 +1606,12 @@ class DirectEffectModel(object):
1572
1606
  dot(grad, vcv), np.transpose(grad)
1573
1607
  ) # V(Grad(X)) = Grad(X).V(X).Grad'(X)
1574
1608
  se = np.sqrt(var)
1575
- zscore = z_score(conf)
1576
- llci = betas - (se * zscore)
1577
- ulci = betas + (se * zscore)
1609
+ if self._is_logit:
1610
+ crit = z_score(conf)
1611
+ else: # OLS intervals use the t distribution, as PROCESS does (#40)
1612
+ crit = t_score(conf, self._model.estimation_results["df_e"])
1613
+ llci = betas - (se * crit)
1614
+ ulci = betas + (se * crit)
1578
1615
  return betas, se, llci, ulci
1579
1616
 
1580
1617
  def coeff_summary(self):
pyprocessmacro/process.py CHANGED
@@ -16,8 +16,6 @@ from .models import (
16
16
  )
17
17
  from .utils import plot_conditional_effects, gen_moderators
18
18
 
19
- warnings.simplefilter("default")
20
-
21
19
 
22
20
  class Process(object):
23
21
  __var_kws__ = {"x", "m", "w", "z", "v", "q", "y"}
@@ -31,6 +29,7 @@ class Process(object):
31
29
  "effsize",
32
30
  "jn",
33
31
  "hc3",
32
+ "cov_type",
34
33
  "controls_in",
35
34
  "total",
36
35
  "center",
@@ -564,6 +563,7 @@ class Process(object):
564
563
  effsize=False,
565
564
  jn=False,
566
565
  hc3=False,
566
+ cov_type="standard",
567
567
  controls=None,
568
568
  controls_in="all",
569
569
  total=False,
@@ -601,7 +601,8 @@ class Process(object):
601
601
  :param boot: int
602
602
  The number of bootstrap repetitions for the estimation of the SE and CI in indirect effects.
603
603
  :param seed: int
604
- The seed to use for bootstrap samples. Specify an integer between 0 and 1e10 for a replicable seed.
604
+ The seed of the bootstrap sampler: an integer between 0 and 2**32 - 1 for reproducible samples,
605
+ or None for a different draw on every run.
605
606
  :param conf: int
606
607
  A value between 51 and 99, representing the desired level of confidence for the confidence intervals
607
608
  :param effsize: bool
@@ -610,6 +611,11 @@ class Process(object):
610
611
  If True, the Johnson-Neymann region of significance will be reported.
611
612
  :param hc3: bool
612
613
  If True, the HC3 estimator will be used for the variance/covariance matrix of the parameters.
614
+ Shorthand for cov_type="HC3".
615
+ :param cov_type: "standard", "HC0", "HC1", "HC2" or "HC3"
616
+ The estimator of the variance/covariance matrix of the OLS parameters: the standard homoskedastic
617
+ estimator, or one of the heteroskedasticity-consistent estimators. Logistic outcome models always
618
+ use the inverse of the Hessian.
613
619
  :param controls: list of string
614
620
  A list of control variables to include to the model(s).
615
621
  :param controls_in: "all", "x_to_m", "all_to_y"
@@ -642,55 +648,65 @@ class Process(object):
642
648
  :param precision:
643
649
  The number of decimal places to display in the summary of the model results.
644
650
  """
645
- if kwargs.pop("mc", None):
651
+ if mc:
646
652
  warnings.warn(
647
- "The argument 'mc' for Monte-Carlo simulations is not supported",
648
- DeprecationWarning,
653
+ "The argument 'mc' for Monte-Carlo confidence intervals is not supported; "
654
+ "bootstrap confidence intervals are used.",
655
+ UserWarning,
656
+ stacklevel=2,
649
657
  )
650
658
  if kwargs.pop("normal", None):
651
659
  warnings.warn(
652
660
  "The argument 'normal' for normal theory tests is not supported. "
653
661
  "Bootstrapped CI are recommended.",
654
- DeprecationWarning,
662
+ UserWarning,
663
+ stacklevel=2,
655
664
  )
656
665
  if kwargs.pop("varorder", None):
657
666
  warnings.warn(
658
667
  "The argument 'varorder' for normal theory tests is not supported. "
659
668
  "Bootstrapped CI are recommended.",
660
- DeprecationWarning,
669
+ UserWarning,
670
+ stacklevel=2,
661
671
  )
662
672
  if kwargs.pop("varlist", None):
663
673
  warnings.warn(
664
674
  "The 'varlist' is not required. To specify controls, use the 'controls' arguments",
665
- DeprecationWarning,
675
+ UserWarning,
676
+ stacklevel=2,
666
677
  )
667
678
  if kwargs.pop("coeffci", None):
668
679
  warnings.warn(
669
- "The argument 'coeffci' is not supported.", DeprecationWarning
680
+ "The argument 'coeffci' is not supported.",
681
+ UserWarning,
682
+ stacklevel=2,
670
683
  )
671
684
  if kwargs.pop("plot", None):
672
685
  warnings.warn(
673
686
  "The argument 'plot' is not supported. Check the 'plot_conditional_direct_effects() and"
674
687
  "'plot_conditional_indirect_effects()' methods instead.",
675
- DeprecationWarning,
688
+ UserWarning,
689
+ stacklevel=2,
676
690
  )
677
691
  if kwargs.pop("save", None):
678
692
  warnings.warn(
679
693
  "The argument 'save' is not supported. Call the 'get_bootstrap_estimates() method to recover"
680
694
  "the bootstrap samples instead.",
681
- DeprecationWarning,
695
+ UserWarning,
696
+ stacklevel=2,
682
697
  )
683
- if kwargs.pop("effsize", None):
698
+ if effsize:
684
699
  warnings.warn(
685
- "The argument 'effsize' for effect sizes is not supported yet."
686
- "It is coming in future versions of PyProcessMacro.",
687
- SyntaxWarning,
700
+ "The argument 'effsize' for effect sizes is not supported and is ignored.",
701
+ UserWarning,
702
+ stacklevel=2,
688
703
  )
689
- if kwargs.pop("jn", None):
704
+ if jn:
690
705
  warnings.warn(
691
- "The argument 'jn' for the Johnson-Neyman region of significance is not supported."
692
- "Call the 'floodlight_direct_effect()' and 'floodlight_indirect_effect()' methods instead.",
693
- DeprecationWarning,
706
+ "The argument 'jn' for the Johnson-Neyman region of significance is not supported and is "
707
+ "ignored. Call the 'floodlight_direct_effect()' and 'floodlight_indirect_effect()' methods instead.",
708
+ UserWarning,
709
+ stacklevel=2,
694
710
  )
695
711
 
696
712
  if model == 6:
@@ -720,13 +736,21 @@ class Process(object):
720
736
 
721
737
  # Check the congruence between the model specifications, the model number, and the data, and store the final
722
738
  # list of variables used
739
+ unknown_kwargs = set(kwargs) - self.__var_kws__
740
+ if unknown_kwargs:
741
+ raise TypeError(
742
+ f"Process() got unexpected keyword argument(s): {', '.join(sorted(unknown_kwargs))}. "
743
+ "Variables are x, y, m, w, z, v and q; check the spelling of the options."
744
+ )
723
745
  var_kwargs = {k: v for k, v in kwargs.items() if k in self.__var_kws__}
724
746
 
725
- self.mediators = var_kwargs.get("m")
726
- self.iv = var_kwargs.get("y")
727
-
747
+ # _gen_valid_varlist normalizes every variable argument to a list, so the mediator and
748
+ # outcome names must be read after it runs (#35).
728
749
  self.varlist = self._gen_valid_varlist(var_kwargs)
729
750
 
751
+ self.mediators = list(var_kwargs["m"]) if self.model_num > 3 else []
752
+ self.iv = var_kwargs["y"][0]
753
+
730
754
  # Check the number of mediators supplied to the model:
731
755
  if self.model_num > 3:
732
756
  self.n_meds = len(self.mediators)
@@ -761,7 +785,8 @@ class Process(object):
761
785
  self.outcome_models = self._gen_outcome_models()
762
786
 
763
787
  # Rename the dictionary of custom spotlight values, and generating the spotlight values.
764
- modval_symb = {self._var_to_symb.get(k): v for k, v in modval.items()}
788
+ self._check_moderator_names(modval, "modval")
789
+ modval_symb = {self._var_to_symb[k]: v for k, v in modval.items()}
765
790
  self._spotlight_values = self._gen_spotlight_values(modval_symb)
766
791
 
767
792
  # Generate the direct model.
@@ -777,6 +802,20 @@ class Process(object):
777
802
  if not suppr_init:
778
803
  self._print_init()
779
804
 
805
+ def _check_moderator_names(self, names, argument):
806
+ """
807
+ Raise a ValueError if any of the names is not a moderator of the model (#46).
808
+ :param names: iterable of variable names
809
+ :param argument: the name of the argument being validated, for the error message
810
+ """
811
+ moderators = {self._symb_to_var[s] for s in self._moderators["all"]}
812
+ unknown = [str(n) for n in names if n not in moderators]
813
+ if unknown:
814
+ raise ValueError(
815
+ f"The variable(s) {', '.join(unknown)} in '{argument}' are not moderators of Model "
816
+ f"{self.model_num}. Moderators of this model: {', '.join(sorted(moderators)) or 'none'}."
817
+ )
818
+
780
819
  def _gen_valid_options(self, arguments):
781
820
  """
782
821
  Validate the arguments specified for the different options used in Process.
@@ -791,8 +830,8 @@ class Process(object):
791
830
  "The option 'conf' must be an integer between 50 and 100, exclusive.\n"
792
831
  )
793
832
 
794
- if not isinstance(seed, int) or ((seed <= 0) or (seed >= 1e9)):
795
- errstr += "The option 'seed' must be an integer between 0 and 1 000 000 000, exclusive.\n"
833
+ if seed is not None and (not isinstance(seed, (int, np.integer)) or not (0 <= seed <= 2**32 - 1)):
834
+ errstr += "The option 'seed' must be None or an integer between 0 and 2**32 - 1.\n"
796
835
 
797
836
  if options["contrast"] not in [True, False]:
798
837
  errstr += "The option 'contrast' must be 'True' or 'False'.\n"
@@ -802,6 +841,12 @@ class Process(object):
802
841
  errstr += "The option 'jn' must be 'True' or 'False'.\n"
803
842
  if options["hc3"] not in [True, False]:
804
843
  errstr += "The option 'hc3' must be 'True' or 'False'.\n"
844
+ if options["cov_type"] not in ["standard", "HC0", "HC1", "HC2", "HC3"]:
845
+ errstr += "The option 'cov_type' must be one of 'standard', 'HC0', 'HC1', 'HC2' or 'HC3'.\n"
846
+ elif options["hc3"] is True:
847
+ if options["cov_type"] not in ["standard", "HC3"]:
848
+ errstr += "The options hc3=True and cov_type disagree; use one or the other.\n"
849
+ options["cov_type"] = "HC3" # hc3 is shorthand for cov_type="HC3" (#52)
805
850
  if options["center"] not in [True, False]:
806
851
  errstr += "The option 'center' must be 'True' or 'False'.\n"
807
852
  if options["quantile"] not in [True, False]:
@@ -810,7 +855,7 @@ class Process(object):
810
855
  errstr += "The option 'jn' must be 'True' or 'False'.\n"
811
856
  if options["logit"] not in [True, False]:
812
857
  errstr += "The option 'logit' must be 'True' or 'False'.\n"
813
- if options["controls_in"] not in ["all", "x_to_tm", "all_to_y"]:
858
+ if options["controls_in"] not in ["all", "x_to_m", "all_to_y"]:
814
859
  errstr += "The option 'controls_in' should be one of 'all', 'x_to_m', 'all_to_y'\n"
815
860
  if not isinstance(options["modval"], dict):
816
861
  errstr += "The option 'modval' must be a dictionary.\n"
@@ -994,9 +1039,9 @@ class Process(object):
994
1039
  """
995
1040
  # Subset the data to the columns used in the model
996
1041
  data = self._data[self.varlist].copy()
997
- n_obs_before = self._data.shape[0]
998
- data = data.dropna().reset_index()
999
- n_obs_after = self._data.shape[0]
1042
+ n_obs_before = data.shape[0]
1043
+ data = data.dropna().reset_index(drop=True)
1044
+ n_obs_after = data.shape[0] # rows that survived dropna (#44)
1000
1045
  n_obs_null = n_obs_before - n_obs_after
1001
1046
 
1002
1047
  # Map each variable name to a unique variable code, and rename the columns in the data.)
@@ -1222,7 +1267,9 @@ class Process(object):
1222
1267
  1. If the two moderators are on two different paths (X to M, or M to Y): both CMM and MMM are reported.
1223
1268
  2. If the two moderators are on the same path and form a 3-way interaction: both CMM and MMM are reported.
1224
1269
  3. If the two moderators are on the same path and do not form a 3-way: the PMM is reported.
1225
- 4. If at least one of the two moderators is present on both paths: no analysis is reported.
1270
+ 4. If a moderator is present on both paths (models 58 to 73, 75 and 76): no index is reported, because
1271
+ the indirect effect is not linear in that moderator and the indices assume it is.
1272
+ MM and PMM match PROCESS 2.16. MMM and CMM follow PROCESS 3 (Hayes, 2018), which 2.16 did not report.
1226
1273
 
1227
1274
  This function returns the list of additional analysis to report. If no additional analysis must be performed,
1228
1275
  this list is empty.
@@ -1238,6 +1285,11 @@ class Process(object):
1238
1285
  if n_mods_ind == 0: # No moderators on indirect path, so no additional analysis.
1239
1286
  return []
1240
1287
 
1288
+ # Rule 4: a moderator on both the X-to-M and the M-to-Y paths makes the indirect effect quadratic in
1289
+ # that moderator, and every index below assumes linearity. PROCESS reports no index then (#43).
1290
+ if self._moderators["x_indirect"] & self._moderators["m"]:
1291
+ return []
1292
+
1241
1293
  terms = y_exogvars + m_exogvars
1242
1294
  threeway = any(
1243
1295
  [1 if len(term.split("*")) == 3 else 0 for term in terms]
@@ -1267,6 +1319,7 @@ class Process(object):
1267
1319
  :param path:
1268
1320
  :return:
1269
1321
  """
1322
+ self._check_moderator_names(modval, "modval")
1270
1323
  modval_symb = {self._var_to_symb[k]: v for k, v in modval.items()}
1271
1324
  spotlight_values_symb = self._spotlight_values.copy()
1272
1325
 
@@ -1307,17 +1360,28 @@ class Process(object):
1307
1360
  hue1_values = modval_symb.get(huesymb1,
1308
1361
  spotlight_values_symb[huesymb1])
1309
1362
  hue2_values = [0]
1363
+ else:
1364
+ raise ValueError(
1365
+ f"'hue' accepts one or two moderator names, got {len(hue)}."
1366
+ )
1310
1367
  else:
1311
1368
  huevar1 = None
1312
1369
  huevar2 = None
1313
1370
  hue1_values = [0]
1314
1371
  hue2_values = [0]
1315
1372
 
1316
- col_symb = self._var_to_symb[x]
1317
- col_values = modval_symb.get(col_symb, spotlight_values_symb.get(col_symb, [0]))
1373
+ # Values for the column and row facets (#36)
1374
+ if col is not None:
1375
+ col_symb = self._var_to_symb[col]
1376
+ col_values = modval_symb.get(col_symb, spotlight_values_symb.get(col_symb, [0]))
1377
+ else:
1378
+ col_values = [0]
1318
1379
 
1319
- row_symb = self._var_to_symb[x]
1320
- row_values = modval_symb.get(row_symb, spotlight_values_symb.get(row_symb, [0]))
1380
+ if row is not None:
1381
+ row_symb = self._var_to_symb[row]
1382
+ row_values = modval_symb.get(row_symb, spotlight_values_symb.get(row_symb, [0]))
1383
+ else:
1384
+ row_values = [0]
1321
1385
 
1322
1386
  mod_names = [x, huevar1, huevar2, col, row]
1323
1387
  mod_values = [x_values, hue1_values, hue2_values, col_values, row_values]
@@ -1337,13 +1401,19 @@ class Process(object):
1337
1401
  m_var = self._symb_to_var[m]
1338
1402
  if modval_parsed.get(m_var) is None:
1339
1403
  warnings.warn(
1340
- f"The moderator {m_var} exerts an influence on the effect, but is not specified as a factor on\
1341
- the graph. Its value has been explicitely set to 0.",
1342
- SyntaxWarning,
1404
+ f"The moderator {m_var} exerts an influence on the effect but is not a factor of the graph; "
1405
+ "its value has been set to 0.",
1406
+ UserWarning,
1407
+ stacklevel=3,
1343
1408
  )
1344
1409
  modval_parsed[m_var] = [0]
1345
1410
  return modval_parsed
1346
1411
 
1412
+ @property
1413
+ def dv(self):
1414
+ """The name of the dependent variable (the outcome Y). `iv` holds the same value for compatibility."""
1415
+ return self.iv
1416
+
1347
1417
  # API
1348
1418
  def summary(self):
1349
1419
  """
@@ -1393,17 +1463,18 @@ class Process(object):
1393
1463
  boot_betas_y = iem._boot_betas_y
1394
1464
  boot_betas_m = iem._boot_betas_m
1395
1465
 
1396
- cols_y = [stv[t] for t in iem._exog_terms_y]
1397
- df = pd.DataFrame(boot_betas_y, columns=cols_y)
1398
- df["___"] = stv["y"]
1466
+ frames = []
1467
+ df_y = pd.DataFrame(boot_betas_y, columns=[stv[t] for t in iem._exog_terms_y])
1468
+ df_y.insert(0, "OutcomeName", stv["y"])
1469
+ frames.append(df_y)
1470
+ cols_m = [stv[t] for t in iem._exog_terms_m]
1399
1471
  for i in range(self.n_meds):
1400
- cols_m = [stv[t] for t in iem._exog_terms_m]
1401
1472
  df_m = pd.DataFrame(boot_betas_m[i], columns=cols_m)
1402
- df_m["___"] = stv[f"m{i + 1}"]
1403
- df = df.append(df_m)
1473
+ df_m.insert(0, "OutcomeName", stv[f"m{i + 1}"])
1474
+ frames.append(df_m)
1475
+ # One block per outcome. DataFrame.append was removed in pandas 2.0 (#33).
1476
+ df = pd.concat(frames)
1404
1477
  df.index.name = "BootSample"
1405
- df.insert(0, "OutcomeName", df["___"].values)
1406
- del df["___"]
1407
1478
  return df.reset_index()
1408
1479
 
1409
1480
  def floodlight_indirect_effect(
@@ -1432,9 +1503,9 @@ class Process(object):
1432
1503
  if other_modval:
1433
1504
  for k, v in other_modval.items():
1434
1505
  symb = self._var_to_symb.get(k)
1435
- if not mod_symb:
1506
+ if not symb:
1436
1507
  raise ValueError(
1437
- f"The variable {mod_name} is not a variable in the model."
1508
+ f"The variable {k} is not a variable in the model."
1438
1509
  )
1439
1510
  if isinstance(v, list):
1440
1511
  raise ValueError(
@@ -1502,9 +1573,9 @@ class Process(object):
1502
1573
  if other_modval:
1503
1574
  for k, v in other_modval.items():
1504
1575
  symb = self._var_to_symb.get(k)
1505
- if not mod_symb:
1576
+ if not symb:
1506
1577
  raise ValueError(
1507
- f"The variable {mod_name} is not a variable in the model."
1578
+ f"The variable {k} is not a variable in the model."
1508
1579
  )
1509
1580
  if isinstance(v, list):
1510
1581
  raise ValueError(
@@ -1674,14 +1745,15 @@ class Process(object):
1674
1745
  If 'ci', confidence intervals are drawn at each discrete value of the moderator on the x-axis
1675
1746
  If 'none', no confidence interval is drawn.
1676
1747
  :param hue_format: string or None
1677
- By default, the color-code are labeled:
1748
+ By default, the color-codes are labeled:
1678
1749
  'Mod1 at val1' if there is one moderator for 'hue'.
1679
- 'Mod2 at val1, Mod2 at val2' if there are two moderators for 'hue'.
1680
- Alternatively, a string that should be formatted can be passed. The string will receive as arguments:
1750
+ 'Mod1 at val1, Mod2 at val2' if there are two moderators for 'hue'.
1751
+ Alternatively, a format string can be passed. It receives the keyword arguments:
1681
1752
  var1 (the name of the first moderator)
1682
1753
  var2 (the name of the second moderator, if it exists)
1683
1754
  val1 (the value of the first moderator)
1684
- val2 (the value of the second moderator)
1755
+ val2 (the value of the second moderator, if it exists)
1756
+ hue1 and hue2 are accepted as aliases of val1 and val2.
1685
1757
  A valid string would for instance look like this: '{var1} = {val1:.4f}, {var2} = {val2:.4f}'
1686
1758
  :param facet_kws: dict
1687
1759
  A dictionary of arguments that should be passed to the FacetGrid object (such as sharex, sharey, size,
@@ -1757,14 +1829,15 @@ class Process(object):
1757
1829
  If 'ci', confidence intervals are drawn at each discrete value of the moderator on the x-axis
1758
1830
  If 'none', no confidence interval is drawn.
1759
1831
  :param hue_format: string or None
1760
- By default, the color-code are labeled:
1761
- 'Mod1 at val1' if there is two moderator for 'hue'.
1762
- 'Mod2 at val1, Mod2 at val2' if there are two moderators for 'hue'.
1763
- Alternatively, a string that should be formatted can be passed. The string will receive as arguments:
1832
+ By default, the color-codes are labeled:
1833
+ 'Mod1 at val1' if there is one moderator for 'hue'.
1834
+ 'Mod1 at val1, Mod2 at val2' if there are two moderators for 'hue'.
1835
+ Alternatively, a format string can be passed. It receives the keyword arguments:
1764
1836
  var1 (the name of the first moderator)
1765
1837
  var2 (the name of the second moderator, if it exists)
1766
1838
  val1 (the value of the first moderator)
1767
- val2 (the value of the second moderator)
1839
+ val2 (the value of the second moderator, if it exists)
1840
+ hue1 and hue2 are accepted as aliases of val1 and val2.
1768
1841
  A valid string would for instance look like this: '{var1} = {val1:.4f}, {var2} = {val2:.4f}'
1769
1842
  :param facet_kws: dict
1770
1843
  A dictionary of arguments that should be passed to the FacetGrid object (such as sharex, sharey, size,
@@ -1800,16 +1873,3 @@ class Process(object):
1800
1873
  plot_kws,
1801
1874
  err_kws,
1802
1875
  )
1803
-
1804
- # DEPRECATED METHODS
1805
- def plot_indirect_effects(self, *args, **kwargs):
1806
- raise DeprecationWarning(
1807
- "The method 'plot_indirect_effects' has been deprecated. Please use the equivalent method named \
1808
- 'plot_conditional_indirect_effects."
1809
- )
1810
-
1811
- def plot_direct_effects(self, *args, **kwargs):
1812
- raise DeprecationWarning(
1813
- "The method 'plot_direct_effects' has been deprecated. Please use the equivalent method named \
1814
- 'plot_conditional_direct_effects."
1815
- )