test_logistic.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. import inspect
  2. import numpy as np
  3. import pytest
  4. from irae_risk.logistic import DEFAULT_RANDOM_SEED, LogisticPolyRegression
  5. @pytest.fixture
  6. def regression_data():
  7. x = np.linspace(-2.0, 2.0, 20)
  8. y = np.array([0, 1] * 10)
  9. theta = np.array([-0.2, 0.8])
  10. return x, y, theta
  11. def test_stochastic_methods_share_a_fixed_default_seed():
  12. methods_and_seed_parameters = (
  13. (LogisticPolyRegression.fit, "seed"),
  14. (LogisticPolyRegression.goodness_of_fit, "bootstrap_seed"),
  15. (LogisticPolyRegression.get_model_quantiles_normal, "seed"),
  16. (LogisticPolyRegression.get_normal_theta, "seed"),
  17. (LogisticPolyRegression.get_nonparam_boots_theta, "seed"),
  18. (LogisticPolyRegression.get_nonparam_stratified_boots_theta, "seed"),
  19. (LogisticPolyRegression.get_parametric_boots_theta, "seed"),
  20. )
  21. for method, parameter in methods_and_seed_parameters:
  22. default = inspect.signature(method).parameters[parameter].default
  23. assert default == DEFAULT_RANDOM_SEED
  24. def test_differential_evolution_fit_is_reproducible_by_default(
  25. regression_data,
  26. ):
  27. x, y, _ = regression_data
  28. model = LogisticPolyRegression(degree=1)
  29. first = model.fit(x, y, method="diff_evol")
  30. second = model.fit(x, y, method="diff_evol")
  31. np.testing.assert_array_equal(first["theta"], second["theta"])
  32. assert first["cost"] == second["cost"]
  33. def test_x50_and_s50_for_linear_logistic_model():
  34. model = LogisticPolyRegression(degree=1)
  35. theta = np.array([-2.0, 0.5])
  36. x50 = model.get_x50(theta)
  37. assert x50 == pytest.approx(4.0)
  38. assert model.model(np.array([x50]), theta)[0] == pytest.approx(0.5)
  39. assert model.get_s50(theta) == pytest.approx(0.5 / 4.0)
  40. def test_x50_selects_first_crossing_of_unconstrained_polynomial():
  41. model = LogisticPolyRegression(degree=3)
  42. # F(x) = (x + 2) (x - 1) (x - 3)
  43. theta = np.array([6.0, -5.0, -2.0, 1.0])
  44. assert model.get_x50(theta) == pytest.approx(-2.0)
  45. assert model.get_s50(theta) == pytest.approx(15.0 / 4.0)
  46. def test_x50_and_s50_are_invariant_to_monotonic_theta_symmetries():
  47. model = LogisticPolyRegression(degree=3, mono=True)
  48. theta = np.array([-0.5, 0.4, 1.2, -0.3])
  49. equivalent_theta = np.array([-0.5, -0.4, -1.2, 0.3])
  50. x50 = model.get_x50(theta)
  51. assert model.model(np.array([x50]), theta)[0] == pytest.approx(0.5)
  52. assert model.get_x50(equivalent_theta) == pytest.approx(x50)
  53. assert model.get_s50(equivalent_theta) == pytest.approx(
  54. model.get_s50(theta)
  55. )
  56. def test_x50_supports_flat_monotonic_midpoint():
  57. model = LogisticPolyRegression(degree=3, mono=True)
  58. # F(x) = (x - 1)**3 / 3, so p'(x50) = 0 at x50 = 1.
  59. theta = np.array([-1.0 / 3.0, 0.0, 1.0, -1.0])
  60. x50 = model.get_x50(theta)
  61. assert x50 == pytest.approx(1.0, abs=1e-5)
  62. assert model.model(np.array([x50]), theta)[0] == pytest.approx(0.5)
  63. assert model.get_s50(theta) == pytest.approx(0.0, abs=1e-9)
  64. def test_x50_rejects_models_without_a_unique_real_midpoint():
  65. quadratic = LogisticPolyRegression(degree=2)
  66. with pytest.raises(ValueError, match="does not reach 0.5"):
  67. quadratic.get_x50(np.array([1.0, 0.0, 1.0]))
  68. with pytest.raises(ValueError, match="not uniquely defined"):
  69. quadratic.get_x50(np.zeros(3))
  70. def test_x50_validates_theta():
  71. model = LogisticPolyRegression(degree=1)
  72. with pytest.raises(ValueError, match="theta must have shape"):
  73. model.get_x50(np.array([1.0]))
  74. with pytest.raises(ValueError, match="finite"):
  75. model.get_x50(np.array([0.0, np.nan]))
  76. def test_goodness_of_fit_uses_unpenalized_likelihood_by_default(regression_data):
  77. x, y, theta = regression_data
  78. model = LogisticPolyRegression(degree=1, lam=(0.0, 0.5))
  79. result = model.goodness_of_fit(x, y, theta, bootstrap_samples=20)
  80. expected_llf = -model.get_nllf(x, y, theta)
  81. k = len(theta)
  82. n = len(x)
  83. assert result["LLF"] == pytest.approx(expected_llf)
  84. assert result["AIC"] == pytest.approx(2 * k - 2 * expected_llf)
  85. assert result["BIC"] == pytest.approx(k * np.log(n) - 2 * expected_llf)
  86. def test_goodness_of_fit_can_include_regularization(regression_data):
  87. x, y, theta = regression_data
  88. model = LogisticPolyRegression(degree=1, lam=(0.0, 0.5))
  89. unregularized = model.goodness_of_fit(
  90. x, y, theta, bootstrap_samples=20
  91. )
  92. regularized = model.goodness_of_fit(
  93. x,
  94. y,
  95. theta,
  96. regularization=True,
  97. bootstrap_samples=20,
  98. )
  99. penalty = model.penalty(theta)
  100. assert regularized["LLF"] == pytest.approx(
  101. -model.get_cost(x, y, theta)
  102. )
  103. assert unregularized["LLF"] - regularized["LLF"] == pytest.approx(penalty)
  104. assert regularized["AIC"] - unregularized["AIC"] == pytest.approx(
  105. 2 * penalty
  106. )
  107. assert regularized["BIC"] - unregularized["BIC"] == pytest.approx(
  108. 2 * penalty
  109. )
  110. def test_regularization_switch_has_no_effect_without_penalty(regression_data):
  111. x, y, theta = regression_data
  112. model = LogisticPolyRegression(degree=1)
  113. default = model.goodness_of_fit(x, y, theta, bootstrap_samples=20)
  114. regularized = model.goodness_of_fit(
  115. x,
  116. y,
  117. theta,
  118. regularization=True,
  119. bootstrap_samples=20,
  120. )
  121. assert regularized == pytest.approx(default)
  122. def test_goodness_of_fit_reports_reproducible_bootstrap_deviance(regression_data):
  123. x, y, theta = regression_data
  124. model = LogisticPolyRegression(degree=1)
  125. first = model.goodness_of_fit(
  126. x,
  127. y,
  128. theta,
  129. bootstrap_samples=25,
  130. bootstrap_seed=123,
  131. )
  132. second = model.goodness_of_fit(
  133. x,
  134. y,
  135. theta,
  136. bootstrap_samples=25,
  137. bootstrap_seed=123,
  138. )
  139. assert "chi2" not in first
  140. assert "p-value(chi2)" not in first
  141. assert first["deviance"] == pytest.approx(2 * model.get_nllf(x, y, theta))
  142. assert first["p-value(deviance_bootstrap)"] == second[
  143. "p-value(deviance_bootstrap)"
  144. ]
  145. assert 0 < first["p-value(deviance_bootstrap)"] <= 1
  146. assert first["deviance_bootstrap_samples"] == 25
  147. def test_covariance_methods_match_sandwich_formulas(regression_data):
  148. x, y, theta = regression_data
  149. ridge = 0.5
  150. model = LogisticPolyRegression(degree=1, lam=(0.0, ridge))
  151. design = np.column_stack([np.ones_like(x), x])
  152. probabilities = model.model(x, theta)
  153. weights = probabilities * (1 - probabilities)
  154. information = (design.T * weights) @ design
  155. bread = information + 2 * ridge * np.eye(2)
  156. bread_inv = np.linalg.pinv(bread, hermitian=True)
  157. scores = (y - probabilities)[:, None] * design
  158. robust_meat = scores.T @ scores
  159. expected_model = bread_inv @ information @ bread_inv
  160. expected_robust = bread_inv @ robust_meat @ bread_inv
  161. np.testing.assert_allclose(
  162. model.get_cov(x, y, theta),
  163. expected_model,
  164. )
  165. np.testing.assert_allclose(
  166. model.get_cov(x, y, theta, method="robust_sandwich"),
  167. expected_robust,
  168. )
  169. np.testing.assert_allclose(
  170. model.get_cov(x, y, theta, method="inverse_hessian"),
  171. bread_inv,
  172. )
  173. def test_covariance_rejects_unknown_method_and_l1_penalty(regression_data):
  174. x, y, theta = regression_data
  175. model = LogisticPolyRegression(degree=1)
  176. with pytest.raises(ValueError, match="Unknown covariance method"):
  177. model.get_cov(x, y, theta, method="not-a-method")
  178. l1_model = LogisticPolyRegression(degree=1, lam=(0.1, 0.0))
  179. with pytest.raises(ValueError, match="nonzero L1"):
  180. l1_model.get_cov(x, y, theta)