Benchmark¶

In [1]:
import time
import numpy as np

import matplotlib
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec

from fastpynuts import NUTSfinder
from fastpynuts.experimental import NUTSfinderBenchmark

# other
from nuts_finder import NutsFinder
In [2]:
scales = ["01", "03", "10", "20", "60"]
max_levels = [0, 1, 2, 3]

Benchmark Init Times¶

In [3]:
times_init = []
finders = []
x = range(len(scales))
plt.figure(figsize=(6,3))
for ml_ in max_levels:
    times_init_ = []
    finders_ = []
    for scale in scales:
        t0 = time.time()
        mnf = NUTSfinder(f"data/NUTS_RG_{scale}M_2021_4326.geojson", max_level=ml_)
        times_init_.append(time.time()-t0)
        mnfb = NUTSfinderBenchmark(f"data/NUTS_RG_{scale}M_2021_4326.geojson", max_level=ml_)
        finders_.append(mnfb)
    plt.plot(x, times_init_, label=f"max level {ml_}")
    plt.title("$T_{init}$")
    plt.xlabel("scales")
    plt.ylabel("t [s]")
    plt.xticks(x, scales)

    times_init.append(times_init_)
    finders.append(finders_)

plt.legend()
Out[3]:
<matplotlib.legend.Legend at 0x220a9bdd490>
No description has been provided for this image

Benchmark Initialization Steps¶

In [4]:
import re

import cProfile

from io import StringIO
from pstats import Stats

# utilities
def get_stats(pr, *args, sortargs=[], verbose=True, **kwargs):
    stream = StringIO()
    stat = Stats(pr, stream=stream)
    if sortargs:
        stat.sort_stats(*sortargs).print_stats(*args, **kwargs)
    else:
        stat.print_stats(*args, **kwargs)

    value = stream.getvalue()
    if verbose: print(value)
    return value


def parse_lines(bench):
    lines = re.findall("(\d+)\s+(\d+\.\d+)\s+(\d+\.\d+)\s+(\d+\.\d+)\s+(\d+\.\d+)\s+(.+):(\d+)\((\w+)\)", bench, re.MULTILINE)
    parsed = [{key:val for key, val in zip(["ncalls", "tottime", "percall", "cumtime", "percall2", "file", "line", "fun"], line)} for line in lines]
    return parsed


def get_obj_name(parsed):
    with open(parsed["file"], "r") as f:
        class_n = []
        classes = []
        for num, line in enumerate(f, 1):
            if match := re.search("\w*class (.+?)([(].*:|:)", line):
                classes.append(match.group(1))
                class_n.append(num)

    target_line = int(parsed["line"])
    obj_name = [cl_ for n_, cl_ in zip(class_n, classes) if n_ < target_line][-1]
    return obj_name


def plot_benchmark(pr, *funs, axs=None):
    filter_funs = "|".join(funs)
    bench = get_stats(pr, "fastpynuts", filter_funs, sortargs=["cumtime"], verbose=False)

    parsed = parse_lines(bench)

    # replace inits
    for parsed_ in parsed:
        if parsed_["fun"] == "__init__":
            parsed_["fun"] = get_obj_name(parsed_)

    if axs is None: fig, axs = plt.subplots(1, 2, figsize=(14, 5))
    ax, ax_rel = axs
    for i, info in enumerate(parsed):
        ax.bar(i, float(info["cumtime"]), label=" " + info["fun"])
        ax.set_title("Total Time")
        ax_rel.bar(i, float(info["percall2"]), label=" " + info["fun"])
        ax_rel.set_title("Time/Call")

    ax.set_xticks(range(len(parsed)), [p_["fun"] for p_ in parsed], rotation=30, ha="right")
    ax_rel.set_xticks(range(len(parsed)), [p_["fun"] for p_ in parsed], rotation=30, ha="right")
In [5]:
with cProfile.Profile() as pr: mnf_ = NUTSfinder(f"data/NUTS_RG_01M_2021_4326.geojson", max_level=ml_)
plot_benchmark(pr, "__init__", "_load_regions", "_construct_r_tree", "_construct_tree", "_filter_regions", "geometry2polygon" )
No description has been provided for this image


Query Runtime Comparison¶

Comparing the runtime of the different finding strategies:

  • R-Tree
  • Tree
  • Polygon
  • Bbox
In [6]:
# benchmark settings
N_samples = 100
strategies = ["rtree", "rtree_obj", "tree", "tree_rtree", "tree_rtree_obj", "poly", "bbox"]
bounds = [-20.7, 36.4, 36.5, 62.1]

scale = 1
year = 2021
max_level = 3

labels = ["rtree", "rtree_obj", "tree", "tree_rtree", "tree_rtree_obj", "poly", "bbox"]


# run benchmark
x = np.random.uniform(bounds[0], bounds[2], N_samples)
y = np.random.uniform(bounds[1], bounds[3], N_samples)


fig = plt.figure(layout="constrained", figsize=(16, 10))
gs = GridSpec(3, 3, figure=fig, hspace=0.2, wspace=0.1)

durations = []
norm = matplotlib.colors.Normalize(vmin=0, vmax=len(strategies))

for gs_, scale, finder in zip(gs, scales, finders[max_levels.index(max_level)]):
    ax = fig.add_subplot(gs_)

    for i, strategy in enumerate(strategies):
        durations_ = []
        for x_, y_ in zip(x, y):
            color = matplotlib.cm.jet(norm(i))

            t0 = time.time()
            hits = finder.find(x_, y_, method=strategy)
            if not hits: continue
            t1 = time.time()

            duration = t1-t0
            durations_.append(duration)

            scatter = ax.scatter(i, duration, 140, edgecolor=color, facecolor="none", label="_no_label")

        if durations_:
            avg = sum(durations_)/len(durations_)
        else:
            continue
        scatter.set_label(f"$\mu = {avg:.5f} s$")

        durations.append(durations_)

    ax.set_title(f"Scale {scale}")
    # ax.set_xlabel("method", fontweight="bold")
    ax.set_ylabel("t [s]", fontweight="bold")
    ax.set_xticks(range(len(labels)), labels)
    ax.legend()

plt.suptitle("Query Runtime", fontsize=18, fontweight="bold")
Out[6]:
Text(0.5, 0.98, 'Query Runtime')
No description has been provided for this image
In [7]:
from shapely import intersects_xy
from shapely.geometry import Point


def create_random_points_uniform(geom, N, min_distance_boundary=0):
    """
    Create random points in (`min_distance_boundary` >= 0) or outside (`min_distance_boundary` < 0) of a shapely geometry.
    **Note:** If `min_distance_boundary` is too large to be feasible (outside of the bounds for negative values or larger than the distance of the CPI), this function will run forever.
    """
    minx, miny, maxx, maxy = geom.bounds

    points = []
    while len(points) < N:
        x = np.random.uniform(minx, maxx, size=1)[0]
        y = np.random.uniform(miny, maxy, size=1)[0]

        intersects = intersects_xy(geom, x, y)
        if intersects and min_distance_boundary >= 0:
            if geom.boundary.distance(Point(x, y)) >= min_distance_boundary:
                points.append([x, y])
        elif not intersects and min_distance_boundary < 0:
            if - geom.boundary.distance(Point(x, y)) <= min_distance_boundary:
                points.append([x, y])

    return points

benchmark selected methods

In [8]:
%matplotlib widget

def benchmark_strategies(nf, N_samples=100, valid_point=True, warmup=10, strategies=["rtree", "rtree_obj", "tree", "tree_rtree", "tree_rtree_obj", "poly", "bbox"]):

    points = []
    for region in np.random.choice(nf, N_samples+warmup): points.extend(create_random_points_uniform(region.geom, 1))
    x = [p[0] for p in points]
    y = [p[1] for p in points]

    plt.figure(figsize=(12, 8))
    durations = []
    scatters = []
    norm = matplotlib.colors.Normalize(vmin=0, vmax=len(strategies))

    for i, strategy in enumerate(strategies):
        durations_ = []
        for j, (x_, y_) in enumerate(zip(x, y)):
            color = matplotlib.cm.jet(norm(i))

            t0 = time.time()
            if strategy.startswith("_"):
                hits = vars()["nf_nuts_finder"].find(lon=x_, lat=y_)
            else:
                hits = nf.find(x_, y_, method=strategy, valid_point=valid_point)
            if not hits: continue
            if j < warmup: continue
            t1 = time.time()

            duration = t1-t0
            durations_.append(duration)

            scatter = plt.scatter(i, duration, 140, edgecolor=color, facecolor="none", label="_no_label")

        if not durations_: continue

        scatters.append(scatter)
        durations.append(durations_)

    min_time = max([min([sum(durations_) for durations_ in durations]), 1e-7])
    for durations_, scatter_ in zip(durations, scatters):
        avg = sum(durations_)/len(durations_)
        if sum(durations_) == min_time:
            scatter_.set_label(f"$\mu = {avg:.5f} \; (N={len(durations_)}) \;\;\;\;\;\; \Sigma = {f'{sum(durations_): 6.2f}'.replace(' ', f'{chr(92)} {chr(92)} ')}$")
        else:
            scatter_.set_label(f"$\mu = {avg:.5f} \; (N={len(durations_)}) \;\;\;\;\;\; \Sigma = {f'{sum(durations_): 6.2f}'.replace(' ', f'{chr(92)} {chr(92)} ')} \;\;\;\;\;\;\;\;\; \\mathbf{{\\times  {f'{sum(durations_)/min_time: 5.1f}'.replace(' ', f'{chr(92)} {chr(92)} ')}}}$")

    plt.title(f"Query Runtime (scale {nf.scale})" + (f" (valid_point)" if valid_point else ""), fontweight="bold", fontsize=15)
    plt.ylabel("t [s]", fontweight="bold")
    plt.xticks(range(len(strategies)), [st_ if not st_.startswith("_") else st_[1:] for st_ in strategies])
    plt.legend(title="       mean [s]      (#)            total [s]        factor", title_fontproperties={"weight": "bold"})


N = 1000
benchmark_strategies(finders[max_levels.index(max_level)][scales.index("01")], N_samples=N, strategies=["rtree", "tree", "poly", "bbox"])
benchmark_strategies(finders[max_levels.index(max_level)][scales.index("20")], N_samples=N, strategies=["rtree", "tree", "poly", "bbox"])
Figure
No description has been provided for this image
Figure
No description has been provided for this image

benchmark all methods

In [9]:
benchmark_strategies(finders[max_levels.index(max_level)][scales.index("01")], N_samples=N)
benchmark_strategies(finders[max_levels.index(max_level)][scales.index("20")], N_samples=N)
Figure
No description has been provided for this image
Figure
No description has been provided for this image

and other NUTS packages:

In [10]:
def get_nuts_finder(scale, year, retries=30):
    for _ in range(retries):
        try: return NutsFinder(scale=int(scale), year=year)
        except: pass
    raise Exception(f"Could not acquire NutsFinder in {retries} tries.")

other_nuts_finder = {scale: get_nuts_finder(scale, 2021) for scale in scales}
In [11]:
# benchmark settings
N_samples = 100
strategies = ["rtree", "tree", "poly", "bbox", "_other_nuts_finder"]
bounds = [-20.7, 36.4, 36.5, 62.1]

scale = 1
year = 2021
max_level = 3

labels = ["rtree", "tree", "poly", "bbox", "Nuts-Finder"]


# run benchmark
x = np.random.uniform(bounds[0], bounds[2], N_samples)
y = np.random.uniform(bounds[1], bounds[3], N_samples)


fig = plt.figure(layout="constrained", figsize=(16, 10))
gs = GridSpec(3, 3, figure=fig, hspace=0.2, wspace=0.1)

durations = []
norm = matplotlib.colors.Normalize(vmin=0, vmax=len(strategies))

for gs_, scale, finder in zip(gs, scales, finders[max_levels.index(max_level)]):
    ax = fig.add_subplot(gs_)

    for i, strategy in enumerate(strategies):
        durations_ = []
        for x_, y_ in zip(x, y):
            color = matplotlib.cm.jet(norm(i))

            t0 = time.time()
            if strategy.startswith("_"):
                hits = vars()[strategy[1:]][scale].find(lon=x_, lat=y_)
            else:
                hits = finder.find(x_, y_, method=strategy)
            if not hits: continue
            t1 = time.time()

            duration = t1-t0
            durations_.append(duration)

            scatter = ax.scatter(i, duration, 140, edgecolor=color, facecolor="none", label="_no_label")

        if durations_:
            avg = sum(durations_)/len(durations_)
        else:
            continue
        scatter.set_label(f"$\mu = {avg:.5f} s$")

        durations.append(durations_)

    ax.set_title(f"Scale {scale}")
    # ax.set_xlabel("method", fontweight="bold")
    ax.set_ylabel("t [s]", fontweight="bold")
    ax.set_xticks(range(len(labels)), labels)
    ax.legend()

plt.suptitle("Query Runtime", fontsize=18, fontweight="bold")
Out[11]:
Text(0.5, 0.98, 'Query Runtime')
Figure
No description has been provided for this image

For a few selected points, run the query for multiple maximum NUTS levels. Also determine if the results if the results of different scales are equal for the maximum scale.

Runtime when skipping validity checks¶

Using valid_point = True, a potential speedup is possible when assuming that the queried point is contained in exactly one region per level.

In [12]:
def benchmark_valid_point(nf, N_samples=100, warmup=10, strategies=["rtree", "tree", "bbox"]):

    valid_points = []
    for region in np.random.choice(nf, N_samples+warmup): valid_points.extend(create_random_points_uniform(region.geom, 1))
    x = [p[0] for p in valid_points]
    y = [p[1] for p in valid_points]

    plt.figure(figsize=(12, 8))
    durations = [None] * 2 * len(strategies)
    scatters = [None] * 2 * len(strategies)
    norm = matplotlib.colors.Normalize(vmin=0, vmax=2*len(strategies))

    for k, valid_point in enumerate([True, False]):
        for i, strategy in enumerate(strategies):
            durations_ = []
            for j, (x_, y_) in enumerate(zip(x, y)):
                color = matplotlib.cm.jet(norm(2*i + k))

                t0 = time.time()
                hits = nf.find(x_, y_, method=strategy, valid_point=valid_point)
                if not hits: continue
                if j < warmup: continue
                t1 = time.time()

                duration = t1-t0
                durations_.append(duration)

                scatter = plt.scatter(2*i + k, duration, 140, edgecolor=color, facecolor="none", label="_no_label")

            if not durations_: continue

            scatters[2*i + k] = scatter
            durations[2*i + k] = durations_

    for l, (durations_, scatter_) in enumerate(zip(durations, scatters)):
        avg = sum(durations_)/len(durations_)
        if not l%2:
            scatter_.set_label(f"$\mu = {avg:.5f}  \;\;\;\;\;\;\;\;\;\;\;\; \Sigma = {sum(durations_):6.2f}$")
        else:
            scatter_.set_label(f"$\mu = {avg:.5f}  \;\;\;\;\;\;\;\;\;\;\;\; \Sigma = {sum(durations_):6.2f} \;\;\;\;\;\;\;\;\;\; \\mathbf{{\\times  {sum(durations_)/sum(durations[l-1]): 6.2f}}}$")


    plt.title(f"Query Runtime (scale {nf.scale})" + (f" valid_point" if valid_point else ""), fontweight="bold", fontsize=15)
    plt.ylabel("t [s]", fontweight="bold")
    plt.xticks(range(2*len(strategies)), [val for pair in zip([strat + "_valid" for strat in strategies], strategies) for val in pair])
    plt.legend(scatters, [scatter.get_label() for scatter in scatters], title=f" mean [s] (N = {N_samples})      total [s]       factor", title_fontproperties={"weight": "bold"})

N = 100
benchmark_valid_point(finders[max_levels.index(max_level)][scales.index("01")], N_samples=N)
benchmark_valid_point(finders[max_levels.index(max_level)][scales.index("20")], N_samples=N)
Figure
No description has been provided for this image
Figure
No description has been provided for this image