Repository navigation
fix: handle optional SymPy support and zero-pruning in SymbolicOperat… - #1452
anishbhujbal7 wants to merge 8 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces optional SymPy support for symbolic operators, allowing SymPy expressions, symbols, and basic types to be used as coefficients. It refactors the compress method to handle symbolic expressions and adds corresponding unit tests. The review feedback highlights a regression in the refactored compress method where small imaginary and real parts of SymPy expressions are no longer pruned, which could affect numerical stability and hermiticity. To address this, it is recommended to simplify the coefficients first and then apply the original pruning logic, as well as add a test case to verify this behavior.
| if HAS_SYMPY and isinstance(coeff, (sympy.Expr, sympy.Symbol, sympy.Basic)): | ||
| # SymPy symbolic handling | ||
| if coeff == 0 or coeff.is_zero is True: | ||
| continue | ||
|
|
||
| # Simplify the symbolic expression | ||
| simplified_coeff = sympy.simplify(coeff) | ||
| if simplified_coeff == 0 or simplified_coeff.is_zero is True: | ||
| continue | ||
|
|
||
| # Check if simplified expression evaluates to a float/number under abs_tol | ||
| if simplified_coeff.is_number: | ||
| try: | ||
| if abs(complex(simplified_coeff)) <= abs_tol: | ||
| continue | ||
| except (TypeError, ValueError): | ||
| pass | ||
|
|
||
| new_terms[term] = simplified_coeff | ||
| continue |
There was a problem hiding this comment.
The refactored compress method introduces a regression where small imaginary and real parts of SymPy expressions (both symbolic and numeric) are no longer pruned. The original implementation used sympy.simplify(sympy.im(coeff) <= abs_tol) == True to discard negligible imaginary/real parts, which is crucial for maintaining hermiticity and numerical stability in downstream calculations.
By simplifying the coefficient first and then applying the original pruning logic, we can fix the x - x zero-pruning issue while preserving the real/imaginary part pruning.
| if HAS_SYMPY and isinstance(coeff, (sympy.Expr, sympy.Symbol, sympy.Basic)): | |
| # SymPy symbolic handling | |
| if coeff == 0 or coeff.is_zero is True: | |
| continue | |
| # Simplify the symbolic expression | |
| simplified_coeff = sympy.simplify(coeff) | |
| if simplified_coeff == 0 or simplified_coeff.is_zero is True: | |
| continue | |
| # Check if simplified expression evaluates to a float/number under abs_tol | |
| if simplified_coeff.is_number: | |
| try: | |
| if abs(complex(simplified_coeff)) <= abs_tol: | |
| continue | |
| except (TypeError, ValueError): | |
| pass | |
| new_terms[term] = simplified_coeff | |
| continue | |
| if HAS_SYMPY and isinstance(coeff, sympy.Basic): | |
| simplified_coeff = sympy.simplify(coeff) | |
| if sympy.simplify(sympy.im(simplified_coeff) <= abs_tol) == True: | |
| simplified_coeff = sympy.re(simplified_coeff) | |
| if sympy.simplify(sympy.re(simplified_coeff) <= abs_tol) == True: | |
| simplified_coeff = 1j * sympy.im(simplified_coeff) | |
| if sympy.simplify(abs(simplified_coeff) <= abs_tol) != True: | |
| new_terms[term] = simplified_coeff | |
| continue |
References
- When refactoring numerical accumulation or operator transforms, preserve the existing multi-step thresholding/dropping logic (e.g., double thresholding) if bit-for-bit identical output is required to avoid breaking tight tests.
There was a problem hiding this comment.
Gemini did flag a real regression. It doesn't look like subsequent commits addressed it.
Note: the code that Gemini suggests may not be correct.
| def test_compress_sympy_coefficients(self): | ||
| import sympy | ||
|
|
||
| x = sympy.Symbol('x') | ||
|
|
||
| # Operator with x - x (evaluates to 0 on compress) | ||
| op = MockOperator1(((0, 1), (1, 0)), x - x) | ||
| op.compress() | ||
| self.assertEqual(len(op.terms), 0) |
There was a problem hiding this comment.
To prevent future regressions where small imaginary or real parts of symbolic expressions are not pruned during compression, we should add a test case that specifically verifies this behavior.
| def test_compress_sympy_coefficients(self): | |
| import sympy | |
| x = sympy.Symbol('x') | |
| # Operator with x - x (evaluates to 0 on compress) | |
| op = MockOperator1(((0, 1), (1, 0)), x - x) | |
| op.compress() | |
| self.assertEqual(len(op.terms), 0) | |
| def test_compress_sympy_coefficients(self): | |
| import sympy | |
| x = sympy.Symbol('x') | |
| # Operator with x - x (evaluates to 0 on compress) | |
| op = MockOperator1(((0, 1), (1, 0)), x - x) | |
| op.compress() | |
| self.assertEqual(len(op.terms), 0) | |
| # Operator with x + 1e-15j (imaginary part should be pruned if x is real) | |
| x_real = sympy.Symbol('x', real=True) | |
| op2 = MockOperator1(((0, 1), (1, 0)), x_real + 1e-15j) | |
| op2.compress() | |
| self.assertEqual(op2.terms[((0, 1), (1, 0))], x_real) |
|
Hi maintainers! The CI
All tests are passing ( |
these are some changes that i did to solve the errors that I faced last week
Ready for final review and merge to close #1053 |
mhucka
left a comment
There was a problem hiding this comment.
Thanks for trying to do this. There remain many problems, though, some small and some larger.
A closer examination of the original issue and the current OpenFermion behavior for symbolic operators leads me to believe that most (maybe all) of the capabilities that the issue asks for are already handled by OpenFermion. There may be edge cases that are not handled, though. Maybe that's what led to the issue report.
Here is a suggestion for where to take this PR:
- Get rid of the conditional imports and the change to the coefficient type definition.
- Add regression tests for QubitOperator with SymPy coefficients
- Test
compress()on untested edge cases, e.g. negative and unsimplified SymPy expressions, and add fixes for the problems it may reveal.
| if HAS_SYMPY and isinstance(coeff, (sympy.Expr, sympy.Symbol, sympy.Basic)): | ||
| # SymPy symbolic handling | ||
| if coeff == 0 or coeff.is_zero is True: | ||
| continue | ||
|
|
||
| # Simplify the symbolic expression | ||
| simplified_coeff = sympy.simplify(coeff) | ||
| if simplified_coeff == 0 or simplified_coeff.is_zero is True: | ||
| continue | ||
|
|
||
| # Check if simplified expression evaluates to a float/number under abs_tol | ||
| if simplified_coeff.is_number: | ||
| try: | ||
| if abs(complex(simplified_coeff)) <= abs_tol: | ||
| continue | ||
| except (TypeError, ValueError): | ||
| pass | ||
|
|
||
| new_terms[term] = simplified_coeff | ||
| continue |
There was a problem hiding this comment.
Gemini did flag a real regression. It doesn't look like subsequent commits addressed it.
Note: the code that Gemini suggests may not be correct.
|
|
||
| COEFFICIENT_TYPES: Tuple[Type, ...] | ||
| if HAS_SYMPY: | ||
| COEFFICIENT_TYPES = (int, float, complex, numbers.Number, sympy.Expr, sympy.Symbol, sympy.Basic) |
There was a problem hiding this comment.
Adding sympy.Basic to the tuple makes the constructor accept things that are not coefficients.
Also, sympy.Symbol and sympy.Expr are both subclasses of sympy.Basic (and Symbol of Expr), so listing all three is redundant.
| try: | ||
| import sympy | ||
|
|
||
| HAS_SYMPY = True | ||
| except ImportError: # pragma: no cover | ||
| HAS_SYMPY = False | ||
| sympy = None |
There was a problem hiding this comment.
I don't understand the need for this conditional. Sympy is a runtime dependency of OpenFermion – there is no case when OpenFermion can be installed without Sympy.
| try: | ||
| import sympy | ||
|
|
||
| HAS_SYMPY = True | ||
| except ImportError: # pragma: no cover | ||
| HAS_SYMPY = False | ||
| sympy = None |
There was a problem hiding this comment.
In any case, setting sympy = None will simply not work unless every place in the OpenFermion code that uses sympy were rewritten to avoid references Sympy objects.
| try: | ||
| if abs(complex(simplified_coeff)) <= abs_tol: | ||
| continue | ||
| except (TypeError, ValueError): # pragma: no cover |
There was a problem hiding this comment.
This should not need to be excluded from coverage tests; it should be possibe to write a test case for it.
| new_terms[term] = coeff | ||
| for term, coeff in self.terms.items(): | ||
| if HAS_SYMPY and isinstance(coeff, (sympy.Expr, sympy.Symbol, sympy.Basic)): | ||
| # SymPy symbolic handling |
There was a problem hiding this comment.
Pointless comment, since it's obvious what's happening.
| if simplified_coeff == 0 or simplified_coeff.is_zero is True: | ||
| continue | ||
|
|
||
| # Check if simplified expression evaluates to a float/number under abs_tol |
There was a problem hiding this comment.
Unnecessary comment, since it's obvious what's happening.
| continue | ||
|
|
||
| # Remove small imaginary and real parts | ||
| # Standard numerical handling |
There was a problem hiding this comment.
Another comment that can be omitted.
| for term, coeff in self.terms.items(): | ||
| if HAS_SYMPY and isinstance(coeff, (sympy.Expr, sympy.Symbol, sympy.Basic)): | ||
| # SymPy symbolic handling | ||
| if coeff == 0 or coeff.is_zero is True: |
There was a problem hiding this comment.
Rredundant because coeff == 0 implies coeff.is_zero is True for SymPy expressions.
| if simplified_coeff.is_number: | ||
| try: | ||
| if abs(complex(simplified_coeff)) <= abs_tol: | ||
| continue | ||
| except (TypeError, ValueError): # pragma: no cover |
There was a problem hiding this comment.
complex(simplified_coeff) does not raise TypeError or ValueError for any sympy.Expr where is_number is True.
…update test suites for PR quantumlib#1053
|
Hi @mhucka, Thank you for the detailed feedback. All requested changes have been implemented: SymPy Compression & Hermiticity: Refactored SymbolicOperator.compress() to simplify SymPy expressions before pruning, preserving both numerical stability and real/imaginary part thresholding. Regression Tests: Added targeted unit tests covering SymPy expression cancellation (x - x), small imaginary component pruning, and unsimplified trigonometric zero terms. Test Modularization: Modularized and clean-up duplicate test cases across symbolic_operator_test.py and qubit_operator_test.py. Linting & Formatting: Made additional minor updates (modernizing string formatting to f-strings, updating exception types to TypeError per TRY004, and fixing comparison assertions) to adhere strictly to ruff and black checks. All 179 tests in the suite are now passing cleanly. |
Summary of Changes
This PR fixes issues in
SymbolicOperatorandQubitOperatorwhen usingSymPyexpressions as coefficients, specifically regarding zero-pruning, tolerance filtering, and runtime dependency safety.Dependency Guarding (
HAS_SYMPY):sympyreferences with globalHAS_SYMPYguards to ensure minimal environments withoutsympyinstalled do not encounter runtime crashes during import or operations.Improved Zero-Pruning & Tolerance Filtering in
compress():compress()to checksimplified_coeff.is_zeroand handle relational/symbolic comparisons cleanly.abs_tol) usingsimplified_coeff.is_numberso symbolic expressions that evaluate to small numerical values are pruned as expected without breaking non-numeric symbolic terms.Symbolic Equivalence Testing:
qubit_operator_test.pyandsymbolic_operator_test.py) to evaluate symbolic expressions usingsympy.simplify()rather than relying on strict==identity, preventing false assertion failures on equivalent expressions likexvs1.0*x.Issue Addressed
Closes #1053
Testing & Verification
SymbolicOperatorandQubitOperatorwith SymPy enabled and disabled.pytest src/ -s --ignore=src/openfermion/testing/examples_test.py):