blob: da04c98cf0ff8a4d261cb50561d0532922704af1 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import pyspark
import pyspark.sql.functions as f
import pytest
from sklearn.cluster import DBSCAN as sklearnDBSCAN
from tests.test_base import TestBase
from sedona.spark.sql.st_constructors import ST_MakePoint
from sedona.spark.sql.st_functions import ST_Buffer
from sedona.spark.stats import dbscan
import os
@pytest.mark.skipif(
os.getenv("SPARK_REMOTE") is not None and pyspark.__version__ < "4.0.0",
reason="DBSCAN requires checkpoint directory which is not available in Spark Connect mode before Spark 4.0.0",
)
class TestDBScan(TestBase):
@pytest.fixture
def sample_data(self):
return [
{"id": 1, "x": 1.0, "y": 2.0},
{"id": 2, "x": 3.0, "y": 4.0},
{"id": 3, "x": 2.5, "y": 4.0},
{"id": 4, "x": 1.5, "y": 2.5},
{"id": 5, "x": 3.0, "y": 5.0},
{"id": 6, "x": 12.8, "y": 4.5},
{"id": 7, "x": 2.5, "y": 4.5},
{"id": 8, "x": 1.2, "y": 2.5},
{"id": 9, "x": 1.0, "y": 3.0},
{"id": 10, "x": 1.0, "y": 5.0},
{"id": 11, "x": 1.0, "y": 2.5},
{"id": 12, "x": 5.0, "y": 6.0},
{"id": 13, "x": 4.0, "y": 3.0},
]
@pytest.fixture
def sample_dataframe(self, sample_data):
return (
self.spark.createDataFrame(sample_data)
.select(ST_MakePoint("x", "y").alias("arealandmark"), "id")
.repartition(9)
)
def get_expected_result(self, input_data, epsilon, min_pts, include_outliers=True):
labels = (
sklearnDBSCAN(eps=epsilon, min_samples=min_pts)
.fit([[datum["x"], datum["y"]] for datum in input_data])
.labels_
)
expected = [(x[0] + 1, x[1]) for x in list(enumerate(labels))]
clusters = [x for x in set(labels) if (x != -1 or include_outliers)]
cluster_members = {
frozenset([y[0] for y in expected if y[1] == x]) for x in clusters
}
return cluster_members
def get_actual_results(
self,
input_data,
epsilon,
min_pts,
geometry=None,
id=None,
include_outliers=True,
):
result = dbscan(
input_data, epsilon, min_pts, geometry, include_outliers=include_outliers
)
result.show()
id = id or "id"
clusters_members = [
(x[id], x.cluster)
for x in result.collect()
if x.cluster != -1 or include_outliers
]
result.unpersist()
clusters = {
frozenset([y[0] for y in clusters_members if y[1] == x])
for x in {y[1] for y in clusters_members}
}
return clusters
@pytest.mark.parametrize("epsilon", [0.6, 0.7, 0.8])
@pytest.mark.parametrize("min_pts", [3, 4, 5])
def test_dbscan_valid_parameters(
self, sample_data, sample_dataframe, epsilon, min_pts
):
# repeated broadcast joins with this small data size use a lot of RAM on broadcast references
self.spark.conf.set("sedona.join.autoBroadcastJoinThreshold", -1)
self.spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1)
assert self.get_expected_result(
sample_data, epsilon, min_pts
) == self.get_actual_results(sample_dataframe, epsilon, min_pts)
def test_dbscan_valid_parameters_default_column_name(
self, sample_data, sample_dataframe
):
# repeated broadcast joins with this small data size use a lot of RAM on broadcast references
self.spark.conf.set("sedona.join.autoBroadcastJoinThreshold", -1)
self.spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1)
df = sample_dataframe.select(
"id", f.col("arealandmark").alias("geometryFieldName")
)
epsilon = 0.6
min_pts = 4
assert self.get_expected_result(
sample_data, epsilon, min_pts
) == self.get_actual_results(df, epsilon, min_pts)
def test_dbscan_valid_parameters_polygons(self, sample_data, sample_dataframe):
# repeated broadcast joins with this small data size use a lot of RAM on broadcast references
self.spark.conf.set("sedona.join.autoBroadcastJoinThreshold", -1)
self.spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1)
df = sample_dataframe.select(
"id", ST_Buffer(f.col("arealandmark"), 0.000001).alias("geometryFieldName")
)
epsilon = 0.6
min_pts = 4
assert self.get_expected_result(
sample_data, epsilon, min_pts
) == self.get_actual_results(df, epsilon, min_pts)
def test_dbscan_supports_other_distance_function(self, sample_dataframe):
# repeated broadcast joins with this small data size use a lot of RAM on broadcast references
self.spark.conf.set("sedona.join.autoBroadcastJoinThreshold", -1)
self.spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1)
df = sample_dataframe.select(
"id", ST_Buffer(f.col("arealandmark"), 0.000001).alias("geometryFieldName")
)
epsilon = 0.6
min_pts = 4
dbscan(
df,
epsilon,
min_pts,
"geometryFieldName",
use_spheroid=True,
)
def test_dbscan_invalid_epsilon(self, sample_dataframe):
with pytest.raises(Exception):
dbscan(sample_dataframe, -0.1, 5, "arealandmark")
def test_dbscan_invalid_min_pts(self, sample_dataframe):
with pytest.raises(Exception):
dbscan(sample_dataframe, 0.1, -5, "arealandmark")
def test_dbscan_invalid_geometry_column(self, sample_dataframe):
with pytest.raises(Exception):
dbscan(sample_dataframe, 0.1, 5, "invalid_column")
def test_return_empty_df_when_no_clusters(self, sample_data, sample_dataframe):
# repeated broadcast joins with this small data size use a lot of RAM on broadcast references
self.spark.conf.set("sedona.join.autoBroadcastJoinThreshold", -1)
self.spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1)
epsilon = 0.1
min_pts = 10000
assert (
dbscan(
sample_dataframe,
epsilon,
min_pts,
"arealandmark",
include_outliers=False,
).count()
== 0
)
# picked some coefficient we know yields clusters and thus hit the happy case
assert (
dbscan(
sample_dataframe,
epsilon,
min_pts,
"arealandmark",
include_outliers=False,
).schema
== dbscan(sample_dataframe, 0.6, 3, "arealandmark").schema
)
def test_dbscan_doesnt_duplicate_border_points_in_two_clusters(self):
# repeated broadcast joins with this small data size use a lot of RAM on broadcast references
self.spark.conf.set("sedona.join.autoBroadcastJoinThreshold", -1)
self.spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1)
input_df = self.spark.createDataFrame(
[
{"id": 10, "x": 1.0, "y": 1.8},
{"id": 11, "x": 1.0, "y": 1.9},
{"id": 12, "x": 1.0, "y": 2.0},
{"id": 13, "x": 1.0, "y": 2.1},
{"id": 14, "x": 2.0, "y": 2.0},
{"id": 15, "x": 3.0, "y": 1.9},
{"id": 16, "x": 3.0, "y": 2.0},
{"id": 17, "x": 3.0, "y": 2.1},
{"id": 18, "x": 3.0, "y": 2.2},
]
).select(ST_MakePoint("x", "y").alias("geometry"), "id")
# make sure no id occurs more than once
output_df = dbscan(input_df, 1.0, 4)
assert output_df.count() == 9
assert output_df.select("cluster").distinct().count() == 2
def test_return_outliers_false_doesnt_return_outliers(
self, sample_data, sample_dataframe
):
# repeated broadcast joins with this small data size use a lot of RAM on broadcast references
self.spark.conf.set("sedona.join.autoBroadcastJoinThreshold", -1)
self.spark.conf.set("spark.sql.autoBroadcastJoinThreshold", -1)
epsilon = 0.6
min_pts = 3
assert self.get_expected_result(
sample_data, epsilon, min_pts, include_outliers=False
) == self.get_actual_results(
sample_dataframe, epsilon, min_pts, include_outliers=False
)