test_logistic.py 6.3 KB

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