From 7fda864b3618c559264e8c0f073f2e9170217194 Mon Sep 17 00:00:00 2001 From: Deepak Ganesh Date: Sat, 25 Jul 2026 17:56:13 +0530 Subject: [PATCH] Remove intercept for a bare "+ -1" (or "+ 0"), matching "- 1" Root cause: _eval_binary_plus only propagated an *added* intercept from its right operand (via the `intercept` flag); it ignored the right operand's `intercept_removed` flag entirely, keeping only the left operand's intercept state. So "a + b - 1" removed the intercept (removal lives on the left) but "a + b + -1" did not, even though the docs imply appending "+ -1" is equivalent to appending "- 1". Fix: when the right operand of a binary "+" is a *bare* intercept literal (a chain of unary +/- ending directly in 0/1, as in "-1"), honor its intercept removal, mirroring the existing direct "+ 0" (ZERO) handling. A new _is_bare_intercept() helper inspects the parse node so that removal is NOT propagated out of a compound/parenthesized expression such as "1 + (a + 0)", which keeps the intercept by design (existing test). Adds regression cases to _eval_tests and a changelog entry. Fixes #158 --- doc/changes.rst | 6 ++++++ patsy/desc.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/doc/changes.rst b/doc/changes.rst index 878968f4..82de7aa5 100644 --- a/doc/changes.rst +++ b/doc/changes.rst @@ -8,6 +8,12 @@ All Patsy releases are archived at Zenodo: .. image:: https://zenodo.org/badge/DOI/10.5281/zenodo.592075.svg :target: https://doi.org/10.5281/zenodo.592075 +v1.0.3 +------ + +* Fixed intercept handling so that adding ``+ -1`` (or ``+ 0``) to a formula + removes the intercept, consistently with ``- 1`` (gh-158). + v1.0.2 ------ diff --git a/patsy/desc.py b/patsy/desc.py index 210350df..d4cd0101 100644 --- a/patsy/desc.py +++ b/patsy/desc.py @@ -266,6 +266,19 @@ def _eval_binary_plus(evaluator, tree): False, left_expr.terms + right_expr.terms, ) + elif right_expr.intercept_removed and _is_bare_intercept(tree.args[1]): + # e.g. "a + b + -1": a bare intercept literal on the right (unary + # minus applied directly to 1, not wrapped in parentheses) removes + # the intercept, consistently with "- 1". This mirrors the direct + # "+ 0" (ZERO) case handled above. We deliberately do *not* + # propagate intercept removal out of a compound expression such as + # "1 + (a + 0)", which keeps the intercept by design. + return IntermediateExpr( + False, + None, + True, + left_expr.terms + right_expr.terms, + ) else: return IntermediateExpr( left_expr.intercept, @@ -275,6 +288,15 @@ def _eval_binary_plus(evaluator, tree): ) +# True if `node` is a *bare* intercept literal directly on the right of a "+", +# i.e. a chain of unary +/- ending in a numeric literal (as in "-1"), rather +# than a compound/parenthesized expression (as in "(a + 0)"). +def _is_bare_intercept(node): + while node.type in ("+", "-") and len(node.args) == 1: + node = node.args[0] + return node.type in ("ONE", "ZERO", "NUMBER") + + def _eval_binary_minus(evaluator, tree): left_expr = evaluator.eval(tree.args[0]) if tree.args[1].type == "ZERO": @@ -478,6 +500,12 @@ def eval(self, tree, require_evalexpr=True): "a - 1": (False, ["a"]), "a - 0": (True, ["a"]), "1 - a": (True, []), + # gh-158: "+ -1" (or "+ 0") must remove the intercept just like "- 1". + "a + -1": (False, ["a"]), + "a + b + -1": (False, ["a", "b"]), + "a + b + 0": (False, ["a", "b"]), + "a + -1 + b": (False, ["a", "b"]), + "a + -1 + 1": (True, ["a"]), "a + b": (True, ["a", "b"]), "(a + b)": (True, ["a", "b"]), "a + ((((b))))": (True, ["a", "b"]),