0

I'm trying to create an algorithm that detects discontinuities (like vertical asymptotes) within functions between an interval for the purpose of plotting graphs without these discontinuous connecting lines. Also, I only want to evaluate within the interval so bracketing methods like bisection seems good for that.


EDIT

https://en.wikipedia.org/wiki/Classification_of_discontinuities

I realize now there are a few different kinds of discontinuities. I'm mostly interested in jump discontinuities for graphical purposes.


I'm using a bisection method as I've noticed that discontinuities occur where the slope tends to infinity or becomes vertical, so why not narrow in on those sections where the slope keeps increasing and getting steeper and steeper. The point where the slope is a vertical line, that's where the discontinuity exists.

Approach

Currently, my approach is as follows. If you subdivide the interval using a midpoint into 2 sections and compare which section has the steepest slope, then that section with the steepest slope becomes the new subinterval for the next evaluation.

Termination

This repeats until it converges by either slope becoming undefined (reaching infinity) or the left side or the right side of the interval equaling the middle (I think this is because the floating-point decimal runs out of precision and cannot divide any further)

(1.5707963267948966 + 1.5707963267948968) * .5 = 1.5707963267948966

Example

function - floor(x)

enter image description here

(blue = start leftX and rightX, purple = midpoint, green = 2nd iteration midpoints points, red = slope lines per iteration)

As you can see from the image, each bisection narrows into the discontinuity and the slope keeps getting steeper until it becomes a vertical line at the discontinuity point at x=1.

To my surprise this approach seems to work for step functions like floor(x) and tan(x), but it's not that great for 1/x as it takes too many iterations (I'm thinking of creating a hybrid method where I use either illinois or ridders method on the inverse of 1/x as it those tend to find the root in just one iteration).

Javascript Code

/* Math function to test on */
function fn(x) {
    //return (Math.pow(Math.tan(x), 3));
    return 1/x;
    //return Math.floor(x);
    //return x*((x-1-0.001)/(x-1));
}


function slope(x1, y1, x2, y2) {
    return (y2 - y1) / (x2 - x1);
}

function findDiscontinuity(leftX, rightX, fn) {
    while (true) {
        let leftY = fn(leftX);
        let rightY = fn(rightX);
        let middleX = (leftX + rightX) / 2;
        let middleY = fn(middleX);
        let leftSlope = Math.abs(slope(leftX, leftY, middleX, middleY));
        let rightSlope = Math.abs(slope(middleX, middleY, rightX, rightY));

        if (!isFinite(leftSlope) || !isFinite(rightSlope)) return middleX;

        if (middleX === leftX || middleX === rightX) return middleX;

        if (leftSlope > rightSlope) {
            rightX = middleX;
            rightY = middleY;
        } else {
            leftX = middleX;
            leftY = middleY;
        }
    }
}

Problem 1 - Improving detection

For the function x*((x-1-0.001)/(x-1)), the current algorithm has a hard time detecting the discontinuity at x=1 unless I make the interval really small. As an alternative, I could also add most subdivisions but I think the real problem is using slopes as they trick the algorithm into choosing the incorrect subinterval (as demonstrated in the image below), so this approach is not robust enough. Maybe there are some statistical methods that can help determine a more probable interval to select. Maybe something like least squares for measuring the differences and maybe applying weights or biases!

enter image description here

But I don't want the calculations to get too heavy and 5 points of evaluation are the max I would go with per iteration.


EDIT

enter image description here

After looking at problem 1 again, where it selects the wrong (left-hand side) subinterval. I noticed that the only difference between the subintervals was the green midpoint distance from their slope line. So taking inspiration from linear regression, I get the squared distance from the slope line to the midpoints [a, fa] and [b, fb] corresponding to their (left/right) subintervals. And which subinterval has the greatest change/deviation is the one chosen for further subdivision, that is, the greater of the two residuals.

This further improvement resolves problem 1. Although, it now takes around 593 iterations to find the discontinuity for 1/x. So I've created a hybrid function that uses ridders method to find the roots quicker for some functions and then fallback to this new approach. I have given up on slopes as they don't provide enough accurate information.


Problem 2 - Jump Threshold

I'm not sure how to incorporate a jump threshold and what to use for that calculation, don't think slopes would help.

Also, if the line thickness for the graph is 2px and 2 lines of a step function were on top of each other then you wouldn't be able to see the gap of 2px between those lines. So the minimum jump gap would be calculated as such

jumpThreshold = height / (ymax-ymin) = cartesian distance per pixel

minJumpGap = jumpThreshold * 2

But I don't know where to go from here! And once again, maybe there are statistical methods that can help to determine the change in function so that the algorithm can terminate quickly if there's no indication of a discontinuity.

Overall, any help or advice in improving what I got already would be much appreciated!


EDIT

enter image description here

As the above images explains, the more divergent the midpoints are the greater the need for more subdivisions for further inspection for that subinterval. While, if the points mostly follow a straight line trend where the midpoints barely deviate then should exit early. So now it makes sense to use the jumpThreshold in this context.

Maybe there's further analysis that could be done like measuring the curvature of the points in the interval to see whether to terminate early and further optimize this method. Zig zag points or sudden dips would be the most promising. And maybe after a certain number of intervals, keep widening the jumpThreshold as for a discontinuity you expect the residual distance to rapidly increase towards infinity!

Updated code

let ymax = 5, ymin = -5; /* just for example */
let height = 500; /* 500px screen height */
let jumpThreshold = Math.pow(.5 * (ymax - ymin) / height, 2); /* fraction(half) of a pixel! */

/* Math function to test on */
function fn(x) {
    //return (Math.pow(Math.tan(x), 3));
    return 1 / x;
    //return Math.floor(x);
    //return x * ((x - 1 - 0.001) / (x - 1));
    //return x*x;
}

function findDiscontinuity(leftX, rightX, jumpThreshold, fn) {
    /* try 5 interations of ridders method */
    /* usually this approach can find the exact reciprocal root of a discountinuity 
     * in 1 iteration for functions like 1/x compared to the bisection method below */
    let iterations = 5;
    let root = inverseRidderMethod(leftX, rightX, iterations, fn);
    let limit = fn(root);

    if (Math.abs(limit) > 1e+16) {
        if (root >= leftX && root <= rightX) return root;
        return NaN;
    }

    root = discontinuityBisection(leftX, rightX, jumpThreshold, fn);
    return root;
}

function discontinuityBisection(leftX, rightX, jumpThreshold, fn) {
    while (true) {
        let leftY = fn(leftX);
        let rightY = fn(rightX);
        let middleX = (leftX + rightX) * .5;
        let middleY = fn(middleX);

        let a = (leftX + middleX) * .5;
        let fa = fn(a);

        let b = (middleX + rightX) * .5;
        let fb = fn(b);

        let leftResidual = Math.pow(fa - (leftY + middleY) * .5, 2);
        let rightResidual = Math.pow(fb - (middleY + rightY) * .5, 2);

    /* if both subinterval midpoints (fa,fb) barely deviate from their slope lines
         * i.e. they're under the jumpThreshold, then return NaN, 
         * indicating no discountinuity with the current threshold, 
         * both subintervals are mostly straight */
        if (leftResidual < jumpThreshold && rightResidual < jumpThreshold) return NaN;

        if (!isFinite(fa) || a === leftX || a === middleX) return a;
        if (!isFinite(fb) || b === middleX || b === rightX) return b;

        if (leftResidual > rightResidual) {
            /* left hand-side subinterval */
            rightX = middleX;
            middleX = a;
        } else {
            /* right hand-side subinterval */
            leftX = middleX;
            middleX = b;
        }
    }
}

function inverseRidderMethod(min, max, iterations, fn) {
    /* Modified version of RiddersSolver from Apache Commons Math
     * http://commons.apache.org/
     * https://www.apache.org/licenses/LICENSE-2.0.txt
     */
    let x1 = min;
    let y1 = 1 / fn(x1);
    let x2 = max;
    let y2 = 1 / fn(x2);

    // check for zeros before verifying bracketing
    if (y1 == 0) {
        return min;
    }
    if (y2 == 0) {
        return max;
    }

    let functionValueAccuracy = 1e-55;
    let relativeAccuracy = 1e-16;
    let oldx = Number.POSITIVE_INFINITY;
    let i = 0;
    while (i < iterations) {
        // calculate the new root approximation
        let x3 = 0.5 * (x1 + x2);
        let y3 = 1 / fn(x3);
        if (!isFinite(y3)) return NaN;

        if (Math.abs(y3) <= functionValueAccuracy) {
            return x3;
        }
        let delta = 1 - (y1 * y2) / (y3 * y3); // delta > 1 due to bracketing
        let correction = (signum(y2) * signum(y3)) * (x3 - x1) / Math.sqrt(delta);
        let x = x3 - correction; // correction != 0
        if (!isFinite(x)) return NaN;
        let y = 1 / fn(x);

        // check for convergence
        let tolerance = Math.max(relativeAccuracy * Math.abs(x), 1e-16);
        if (Math.abs(x - oldx) <= tolerance) {
            return x;
        }
        if (Math.abs(y) <= functionValueAccuracy) {
            return x;
        }

        // prepare the new interval for the next iteration
        // Ridders' method guarantees x1 < x < x2
        if (correction > 0.0) { // x1 < x < x3
            if (signum(y1) + signum(y) == 0.0) {
                x2 = x;
                y2 = y;
            } else {
                x1 = x;
                x2 = x3;
                y1 = y;
                y2 = y3;
            }
        } else { // x3 < x < x2
            if (signum(y2) + signum(y) == 0.0) {
                x1 = x;
                y1 = y;
            } else {
                x1 = x3;
                x2 = x;
                y1 = y3;
                y2 = y;
            }
        }
        oldx = x;
    }
}

function signum(a) {
    return (a < 0.0) ? -1.0 : ((a > 0.0) ? 1.0 : a);
}


/* TEST */
console.log(findDiscontinuity(.5, .6, jumpThreshold, fn));

Python Code

I don't mind if the solution is provided in Javascript or Python

import math

def fn(x):
    try:
        # return (math.pow(math.tan(x), 3))
        # return 1 / x
        # return math.floor(x)
        return x * ((x - 1 - 0.001) / (x - 1))
    except ZeroDivisionError:
        return float('Inf')

def slope(x1, y1, x2, y2):
    try:
        return (y2 - y1) / (x2 - x1)
    except ZeroDivisionError:
        return float('Inf')

def find_discontinuity(leftX, rightX, fn):

    while True:
        leftY = fn(leftX)
        rightY = fn(rightX)
        middleX = (leftX + rightX) / 2
        middleY = fn(middleX)
        leftSlope = abs(slope(leftX, leftY, middleX, middleY))
        rightSlope = abs(slope(middleX, middleY, rightX, rightY))

        if not math.isfinite(leftSlope) or not math.isfinite(rightSlope):
            return middleX

        if middleX == leftX or middleX == rightX:
            return middleX

        if leftSlope > rightSlope:
            rightX = middleX
            rightY = middleY
        else:
            leftX = middleX
            leftY = middleY
C9C
  • 319
  • 3
  • 14
  • My apologies if you've already discovered and cast aside the following, but https://scicomp.stackexchange.com/questions/2086/what-is-the-best-way-to-find-discontinuities-of-a-black-box-function appears to be related to your quest, particularly the Chebfun references in Pedro's response. – Trentium Jul 31 '20 at 00:23
  • No, I wasn't aware of this! I'll check it out, thanks – C9C Jul 31 '20 at 17:43

0 Answers0