Source code for schrodinger.math.multi_parameter_optimization

"""
Utility functions for MPO (Multi-Parameter Optimization). These provide a way to score
the desirability of some sort of object on a 0-1 scale based on individual
desirability scores of multiple properties of that object.

Use the `get_sigmoid` and `get_double_sigmoid` functions to get Sigmoid objects which
can be called with a parameter value to get the score. A list of scores and weights
can then be passed to get_weighted_score to compute an overall MPO score.
"""
import math
from collections import namedtuple

import numpy as np

# NOTE: The usage of the terms "good" and "bad" throughout are a useful way to
# discuss the different thresholds on a logistic curve in the context of MPO.
# But the fact that GOOD_Y > BAD_Y is ultimately arbitrary and these two
# values can equal anything between 0 and 1, exclusive. (GOOD_Y != BAD_Y)
GOOD_Y = .8  # the score of the threshold for good values
BAD_Y = .2  # the score of the threshold for bad values

# A rate and constant that determines a logistic sigmoid functions shape
Transition = namedtuple('Transition', ['center', 'rate'])
Transition.__new__.__defaults__ = (0.0, 1.0)


[docs]class Sigmoid: """ A sigmoid transformation using a transition that determines the inflection point and the rate of change. Larger rate values lead to a faster transition. example usage: transition = Transition(2.5, 10.0) # a steep 0 to 1 transition transform = Sigmoid(transition) transformed_valued = transform(original_value) :ivar transition: the transition :vartype transition: Transition :: rate positive rate negative 1 --- 1 --- / \ 0 --- 0 --- 0 1 2 3 4... 0 1 2 3 4... """
[docs] def __init__(self, transition): self._transition = transition
def __call__(self, value): center, rate = self._transition return 1 / (1 + np.exp(-rate * (value - center)))
[docs]class DoubleSigmoid: """ A double sigmoid transformation using a left and right transition to determine the inflection points and the rate of change. The left and right transitions are expected to have opposite signs for the rates. Larger rate values lead to a faster transition. example usage: a_side = Transform(1.5, 50.0) # a very steep 0 to 1 transition b_side = Transition(10.0, -1.0) # a regular 1 to 0 transition mpo = DoubleSigmoid(a_side, b_side) transformed_valued = mpo(original_value) :ivar transition_a: the 'left-hand side' transition :vartype transition_a: Transition :ivar transition_b: the 'right-hand side' transition :vartype transition_b: Transition """
[docs] def __init__(self, transition_a, transition_b): if np.sign(transition_a.rate) == np.sign(transition_b.rate): raise ValueError("Sigmoid curve rates must have opposite sign") self._sigmoid_a = Sigmoid(transition_a) self._sigmoid_b = Sigmoid(transition_b) self._intersection = get_intersection(transition_a, transition_b)
def __call__(self, value): sigmoid = self._sigmoid_a if value < self._intersection else self._sigmoid_b return sigmoid(value)
[docs]def get_intersection(transition_a, transition_b): """ Get the intersection point of two logistic functions given their center (inflection) points and rate constants. !!! Rate of transition_a and transition_b cannot be equal or a ZeroDivisionError will be raised. (Two curves with same rates will never intersect unless their centers are equal) :type transition_a: Transition :type transition_b: Transition :rtype: float """ c0, r0 = transition_a c1, r1 = transition_b return (r1 * c1 - r0 * c0) / (r1 - r0)
[docs]def get_rate(good, bad): """ Get the rate for the logistic function given the x values corresponding to the "good" and "bad" thresholds. :param good: The x value corresponding to the good threshold (`GOOD_Y`) :type good: float :param bad: The x value corresponding to the bad threshold (`BAD_Y`) :type bad: float :return: The rate :rtype: float """ return (-math.log((1 / BAD_Y - 1) / (1 / GOOD_Y - 1))) / (bad - good)
[docs]def get_center(good, bad): """ Get the center (inflection point) for the logistic function given the x values corresponding to the "good" and "bad" thresholds. :param good: The x value corresponding to the good threshold (`GOOD_Y`) :type good: float :param bad: The x value corresponding to the bad threshold (`BAD_Y`) :type bad: float :return: The center point :rtype: float """ l_bad = math.log(1 / BAD_Y - 1) l_good = math.log(1 / GOOD_Y - 1) return (l_bad * good - l_good * bad) / (l_bad - l_good)
[docs]def get_sigmoid(good, bad): """ Get a sigmoid logistic function using the given "good" and "bad" cutoffs :param good: The x value corresponding to the good threshold (`GOOD_Y`) :type good: float :param bad: The x value corresponding to the bad threshold (`BAD_Y`) :type bad: float :return: a sigmoid function which can be called with an x value to get the desirability :rtype: Sigmoid """ return Sigmoid(Transition(get_center(good, bad), get_rate(good, bad)))
[docs]def get_double_sigmoid(good1, bad1, good2, bad2): """ Get a double sigmoid function comprised of two logistic functions that fit two points on either function. :param good1: The x value corresponding to the good threshold (`GOOD_Y`) of the first sigmoid :type good1: float :param bad1: The x value corresponding to the bad threshold (`BAD_Y`) of the first sigmoid :type bad1: float :param good2: The x value corresponding to the good threshold (`GOOD_Y`) of the second sigmoid :type good2: float :param bad2: The x value corresponding to the bad threshold (`BAD_Y`) of the second sigmoid :type bad2: float :return: the double sigmoid function which can be called with an x value to get the desirability :rtype: DoubleSigmoid """ if (bad1 < good1) is (bad2 < good2): raise ValueError("Sigmoid curves must have opposite sign") transition_a = Transition(get_center(good1, bad1), get_rate(good1, bad1)) transition_b = Transition(get_center(good2, bad2), get_rate(good2, bad2)) return DoubleSigmoid(transition_a, transition_b)
[docs]def get_weighted_score(scores, weights): """ Return the weighted geometric mean of the given score :type scores: list[float] :type weights: list[float] :rtype: float """ total_weight = sum(weights) if total_weight == 0: return 0. product = 1 for score, weight in zip(scores, weights): product *= pow(score, weight) return pow(product, 1 / total_weight)