Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
453 changes: 230 additions & 223 deletions poetry.lock

Large diffs are not rendered by default.

16 changes: 9 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ readme = "README.md"
requires-python = ">=3.10,<4"
dynamic = ["classifiers"]
dependencies = [
"scipy>=1.13",
"numpy>=2.2",
"numpy (>=2.2,<3)",
"click>=7,<9",
"DendroPy>=4.5,<6.0",
"portion>=2.2",
Expand All @@ -25,11 +24,11 @@ dependencies = [

[project.optional-dependencies]
dev = [
"pytest~=6.2",
"pytest-cov~=2.12",
"pytest-benchmark~=3.4",
"pytest-console-scripts~=1.2",
"hypothesis~=6.14",
"pytest (>=9.0.1,<10.0.0)",
"pytest-cov (>=7.0.0,<8.0.0)",
"pytest-benchmark (>=5.2.3,<6.0.0)",
"pytest-console-scripts (>=1.4.1,<2.0.0)",
"hypothesis (>=6.148.0,<7.0.0)",
]

[project.urls]
Expand Down Expand Up @@ -57,6 +56,9 @@ classifiers = [

[tool.ruff]
line-length = 118
exclude = [
"tact/vendor/**",
]

[tool.ruff.lint]
fixable = ["ALL"]
Expand Down
94 changes: 74 additions & 20 deletions tact/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
import random
import sys
from decimal import Decimal as D
from decimal import Overflow as DecimalOverflow
from math import exp, log

import numpy as np
from scipy.optimize import dual_annealing, minimize, minimize_scalar

# Use vendored optimization functions instead of scipy
from .vendor.pyprima.src.pyprima import minimize as pyprima_minimize
from .vendor.scipy_optimize import minimize_scalar_bounded

# Raise on overflow
np.seterr(all="raise")
Expand Down Expand Up @@ -49,7 +53,20 @@ def wrapped_lik_constant(x, sampling, ages):
Returns:
(float): a likelihood
"""
return lik_constant(get_bd(*x), sampling, ages)
# Check if parameters would result in invalid birth/death rates
# before calling the likelihood function to avoid errors
try:
birth, death = get_bd(*x)
# If birth rate is negative or death rate is negative, return a large penalty
# Since we're minimizing, a large positive value acts as a penalty
if birth <= 0 or death < 0:
return 1e10 # Large penalty value

return lik_constant((birth, death), sampling, ages)
except (ZeroDivisionError, ValueError, OverflowError, FloatingPointError, DecimalOverflow):
# If transformation or likelihood calculation fails due to numerical issues,
# return a large penalty to guide the optimizer away from this region
return 1e10


def wrapped_lik_constant_yule(x, sampling, ages):
Expand All @@ -67,9 +84,10 @@ def wrapped_lik_constant_yule(x, sampling, ages):


def two_step_optim(func, x0, bounds, args):
"""Conduct a two-step function optimization.
"""Conduct function optimization using COBYLA.

First, use the fast L-BFGS-B method, and if that fails, use simulated annealing.
Uses COBYLA (Constrained Optimization BY Linear Approximation) from pyprima,
a derivative-free optimization method that can handle bounded optimization.

Args:
func (callable): function to optimize
Expand All @@ -80,18 +98,54 @@ def two_step_optim(func, x0, bounds, args):
Returns:
params (tuple): optimized parameter values
"""
try:
result = minimize(func, x0=x0, bounds=bounds, args=args, method="L-BFGS-B")
if result["success"]:
return result["x"].tolist()
except FloatingPointError:
pass

result = dual_annealing(func, x0=x0, bounds=bounds, args=args)
if result["success"]:
return result["x"].tolist()

raise Exception(f"Optimization failed: {result['message']} (code {result['status']})")
# Convert x0 to numpy array if needed
x0 = np.asarray(x0)

# Calculate appropriate trust region radius (rhobeg) based on bounds
# rhobeg should be about one tenth of the greatest expected change to a variable
# Using smaller steps can help find more precise solutions
bound_ranges = [b[1] - b[0] for b in bounds]
max_range = max(bound_ranges) if bound_ranges else 100.0
# Set rhobeg to 5% of the maximum range, but at least 0.05 and at most 5
# Smaller steps may help find solutions closer to L-BFGS-B results
rhobeg = max(0.05, min(0.05 * max_range, 5.0))
# Set rhoend to be smaller but still reasonable - 1e-4 instead of default 1e-6
# This allows the algorithm to converge while still taking reasonable steps initially
rhoend = max(1e-6, min(1e-4, 0.01 * rhobeg))

# Use COBYLA for optimization with tuned trust region parameters
# pyprima's minimize accepts bounds as a list of (min, max) tuples, which matches our format
# Options are passed as a dictionary
options = {"rhobeg": rhobeg, "rhoend": rhoend, "quiet": True}
result = pyprima_minimize(func, x0=x0, method="cobyla", bounds=bounds, args=args, options=options)

# Check if optimization was successful based on info code
# Info codes: 0=SMALL_TR_RADIUS, 1=FTARGET_ACHIEVED, 3=MAXFUN_REACHED, 20=MAXTR_REACHED are acceptable
# Negative codes indicate errors (NAN_INF_X=-1, NAN_INF_F=-2, etc.)
if result.info >= 0:
return result.x.tolist()

# If COBYLA fails, raise an error
# Note: Previously this would fall back to dual_annealing, but we've removed
# that dependency. If needed, we could implement a simple random restart
# strategy here.
#
# Original two-step optimization code (commented out):
# try:
# result = minimize(func, x0=x0, bounds=bounds, args=args, method="L-BFGS-B")
# if result["success"]:
# return result["x"].tolist()
# except FloatingPointError:
# pass
# result = dual_annealing(func, x0=x0, bounds=bounds, args=args)
# if result["success"]:
# return result["x"].tolist()

# Get error message from info code
from tact.vendor.pyprima.src.pyprima.common.message import get_info_string

error_msg = get_info_string("COBYLA", result.info)
raise Exception(f"Optimization failed: {error_msg} (code {result.info})")


def optim_bd(ages, sampling, min_bound=1e-9):
Expand Down Expand Up @@ -130,11 +184,11 @@ def optim_yule(ages, sampling, min_bound=1e-9):
death (float): optimized death rate. Always 0.
"""
bounds = (min_bound, 100)
result = minimize_scalar(wrapped_lik_constant_yule, bounds=bounds, args=(sampling, ages), method="Bounded")
if result["success"]:
return (result["x"], 0.0)
result = minimize_scalar_bounded(wrapped_lik_constant_yule, bounds=bounds, args=(sampling, ages))
if result.success:
return (result.x, 0.0)

raise Exception(f"Optimization failed: {result['message']} (code {result['status']})")
raise Exception(f"Optimization failed: {result.message} (code {result.status})")


def p0_exact(t, l, m, rho): # noqa: E741
Expand Down
38 changes: 38 additions & 0 deletions tact/vendor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Vendored Code

This directory contains vendored code from external projects, used to remove dependencies on scipy.

## Contents

- **pyprima/**: Pure Python implementation of COBYLA optimization algorithm
- Source: scipy/_lib/pyprima/pyprima/
- Git commit: 4899522c2a1a613b35bf5599680804ab641adb1d
- License: BSD 3-Clause
- See `pyprima/README.md` for details

- **scipy_optimize/**: Bounded scalar minimization function
- Source: scipy/optimize/_optimize.py
- Git commit: 4899522c2a1a613b35bf5599680804ab641adb1d
- License: BSD 3-Clause (via SciPy)
- See `scipy_optimize/README.md` for details

## Updating Vendored Code

Use the `update_vendor.sh` script in the repository root to update vendored code from a scipy repository:

```bash
./update_vendor.sh [path_to_scipy_repo]
```

If the path is not provided, it defaults to `../scipy`.

## License Compatibility

All vendored code uses BSD 3-Clause license, which is compatible with TACT's MIT license. When distributing TACT, you must:

1. Retain the original copyright notices
2. Include the BSD 3-Clause license text
3. Note that the code comes from SciPy/pyprima

See individual README files in each subdirectory for specific copyright information.

7 changes: 7 additions & 0 deletions tact/vendor/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""
Vendored code from external projects.

This package contains vendored code from scipy and pyprima to remove
the scipy dependency from TACT.
"""

11 changes: 11 additions & 0 deletions tact/vendor/patches/01-__init__.py.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
--- a/src/pyprima/__init__.py
+++ b/src/pyprima/__init__.py
@@ -1,5 +1,6 @@
# Bounds may appear unused in this file but we need to import it to make it available to the user
-from scipy.optimize import NonlinearConstraint, LinearConstraint, Bounds
+# Use compatibility layer instead of scipy
+from ._scipy_compat import NonlinearConstraint, LinearConstraint, Bounds
from .common._nonlinear_constraints import process_nl_constraints
from .common._linear_constraints import (
combine_multiple_linear_constraints,

9 changes: 9 additions & 0 deletions tact/vendor/patches/02-_bounds.py.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
--- a/src/pyprima/common/_bounds.py
+++ b/src/pyprima/common/_bounds.py
@@ -1,4 +1,4 @@
import numpy as np
-from scipy.optimize import Bounds
+from .._scipy_compat import Bounds

def process_bounds(bounds, lenx0):

10 changes: 10 additions & 0 deletions tact/vendor/patches/03-_linear_constraints.py.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
--- a/src/pyprima/common/_linear_constraints.py
+++ b/src/pyprima/common/_linear_constraints.py
@@ -1,4 +1,4 @@
import numpy as np
-from scipy.optimize import LinearConstraint
+from .._scipy_compat import LinearConstraint


def combine_multiple_linear_constraints(constraints):

74 changes: 74 additions & 0 deletions tact/vendor/patches/04-_project.py.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
--- a/src/pyprima/common/_project.py
+++ b/src/pyprima/common/_project.py
@@ -8,7 +8,7 @@ Tom M. Ragonneau (https://ragonneau.github.io) and Zaikun Zhang (https://www.zha

import numpy as np
from ._linear_constraints import LinearConstraint
-from scipy.optimize import OptimizeResult
+from .._scipy_compat import OptimizeResult

# All the accepted scalar types; np.generic correspond to all NumPy types.
scalar_types = (int, float, np.generic)
@@ -119,55 +119,11 @@ def _project(x0, lb, ub, constraints):
return OptimizeResult(x=x_proj)

if constraints['linear'] is not None:
- try:
- # Project the initial guess onto the linear constraints via SciPy.
- from scipy.optimize import minimize
- from scipy.optimize import Bounds as ScipyBounds
- from scipy.optimize import LinearConstraint as ScipyLinearConstraint
-
- linear = constraints['linear']
-
- # To be more efficient, SciPy asks to separate the equality and the inequality constraints into two
- # different LinearConstraint structures
- pc_args_ineq, pc_args_eq = dict(), dict()
- pc_args_ineq['A'], pc_args_eq['A'] = np.asarray([[]]), np.asarray([[]])
- pc_args_ineq['A'] = pc_args_ineq['A'].reshape(0, linear.A.shape[1])
- pc_args_eq['A'] = pc_args_eq['A'].reshape(0, linear.A.shape[1])
- pc_args_ineq['lb'], pc_args_eq['lb'] = np.asarray([]), np.asarray([])
- pc_args_ineq['ub'], pc_args_eq['ub'] = np.asarray([]), np.asarray([])
-
- for i in range(linear.lb.size):
- if linear.lb[i] != linear.ub[i]:
- pc_args_ineq['A'] = np.concatenate((pc_args_ineq['A'], linear.A[i:i+1, :]), axis=0)
- pc_args_ineq['lb'] = np.r_[pc_args_ineq['lb'], linear.lb[i]]
- pc_args_ineq['ub'] = np.r_[pc_args_ineq['ub'], linear.ub[i]]
- else:
- pc_args_eq['A'] = np.concatenate((pc_args_eq['A'], linear.A[i:i+1, :]), axis=0)
- pc_args_eq['lb'] = np.r_[pc_args_eq['lb'], linear.lb[i]]
- pc_args_eq['ub'] = np.r_[pc_args_eq['ub'], linear.ub[i]]
-
- if pc_args_ineq['A'].size > 0 and pc_args_ineq['lb'].size > 0 and pc_args_eq['lb'].size > 0:
- project_constraints = [ScipyLinearConstraint(**pc_args_ineq), ScipyLinearConstraint(**pc_args_eq)]
- elif pc_args_ineq['A'].size > 0 and pc_args_ineq['lb'].size > 0:
- project_constraints = ScipyLinearConstraint(**pc_args_ineq)
- elif pc_args_eq['A'].size > 0:
- project_constraints = ScipyLinearConstraint(**pc_args_eq)
- else:
- project_constraints = ()
-
- # Perform the actual projection.
- ax_ineq = np.dot(pc_args_ineq['A'], x0_c)
- ax_eq = np.dot(pc_args_eq['A'], x0_c)
- if np.greater(ax_ineq, pc_args_ineq['ub']).any() or np.greater(pc_args_ineq['lb'], ax_ineq).any() or \
- np.not_equal(ax_eq, pc_args_eq['lb']).any() or \
- np.greater(x0_c, ub_c).any() or np.greater(lb_c, x0_c).any():
- return minimize(lambda x: np.dot(x - x0_c, x - x0_c) / 2, x0_c, jac=lambda x: (x - x0_c),
- bounds=ScipyBounds(lb_c, ub_c), constraints=project_constraints)
- else:
- # Do not perform any projection if the initial guess is feasible.
- return OptimizeResult(x=x0_c)
-
- except ImportError:
- return OptimizeResult(x=x0_c)
+ # Note: The original code used scipy.optimize.minimize for projection onto
+ # linear constraints. Since we're removing scipy dependency, we'll do a
+ # simple bound projection instead. This is acceptable for our use case.
+ # For more complex linear constraints, a proper projection would be needed.
+ x_proj = np.nanmin((np.nanmax((x0_c, lb_c), axis=0), ub_c), axis=0)
+ return OptimizeResult(x=x_proj)

return OptimizeResult(x=x0_c)

37 changes: 37 additions & 0 deletions tact/vendor/patches/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Vendor Patches

This directory contains patches and compatibility shims for the vendored pyprima code.

## Contents

- **`_scipy_compat.py`**: Compatibility shim that provides minimal implementations of scipy.optimize classes (`Bounds`, `LinearConstraint`, `NonlinearConstraint`, `OptimizeResult`) to allow pyprima to work without scipy as a dependency.

- **`*.patch`**: Unified diff patches that modify the vendored pyprima code to use the compatibility shim instead of importing from scipy directly.

## Patches

1. **`01-__init__.py.patch`**: Changes imports in `src/pyprima/__init__.py` to use the compatibility shim.
2. **`02-_bounds.py.patch`**: Changes imports in `src/pyprima/common/_bounds.py` to use the compatibility shim.
3. **`03-_linear_constraints.py.patch`**: Changes imports in `src/pyprima/common/_linear_constraints.py` to use the compatibility shim.
4. **`04-_project.py.patch`**: Changes imports in `src/pyprima/common/_project.py` to use the compatibility shim.

## Application

These patches are automatically applied by the `update_vendor.sh` script after updating the vendored code from the scipy repository. The script:

1. Copies `_scipy_compat.py` to the pyprima source directory
2. Applies all `.patch` files in order

## Manual Application

If you need to apply patches manually:

```bash
cd tact/vendor/pyprima
cp ../patches/_scipy_compat.py src/pyprima/
patch -p1 < ../patches/01-__init__.py.patch
patch -p1 < ../patches/02-_bounds.py.patch
patch -p1 < ../patches/03-_linear_constraints.py.patch
patch -p1 < ../patches/04-_project.py.patch
```

Loading
Loading