高效的字符串后缀检测

时间:2019-02-01 14:39:13

标签: python apache-spark pyspark apache-spark-sql string-matching

我正在使用一个巨大的数据集上的PySpark,我想根据另一个数据帧中的字符串来过滤数据帧。例如,

count_table %>% 
    group_by(A) %>%
    top_n(1, n)

我假设dd = spark.createDataFrame(["something.google.com","something.google.com.somethingelse.ac.uk","something.good.com.cy", "something.good.com.cy.mal.org"], StringType()).toDF('domains') +----------------------------------------+ |domains | +----------------------------------------+ |something.google.com | |something.google.com.somethingelse.ac.uk| |something.good.com.cy | |something.good.com.cy.mal.org | +----------------------------------------+ dd1 = spark.createDataFrame(["google.com", "good.com.cy"], StringType()).toDF('gooddomains') +-----------+ |gooddomains| +-----------+ |google.com | |good.com.cy| +-----------+ domains是有效的域名。

我想做的是过滤掉gooddomains中不以dd结尾的匹配字符串。因此,在上面的示例中,我想过滤掉第1行和第3行,以

结尾
dd1

我当前的解决方案(如下所示)只能说明最多3个“单词”的域。如果我要在+----------------------------------------+ |domains | +----------------------------------------+ |something.google.com.somethingelse.ac.uk| |something.good.com.cy.mal.org | +----------------------------------------+ 中添加verygood.co.ac.uk(即白名单),那么它将失败。

dd1

我正在将Spark 2.3.0与Python 2.7.5结合使用。

2 个答案:

答案 0 :(得分:9)

让我们扩展domains以获得更好的覆盖范围:

domains = spark.createDataFrame([
    "something.google.com",  # OK
    "something.google.com.somethingelse.ac.uk", # NOT OK 
    "something.good.com.cy", # OK 
    "something.good.com.cy.mal.org",  # NOT OK
    "something.bad.com.cy",  # NOT OK
    "omgalsogood.com.cy", # NOT OK
    "good.com.cy",   # OK 
    "sogood.example.com",  # OK Match for shorter redundant, mismatch on longer
    "notsoreal.googleecom" # NOT OK
], "string").toDF('domains')

good_domains =  spark.createDataFrame([
    "google.com", "good.com.cy", "alsogood.com.cy",
    "good.example.com", "example.com"  # Redundant case
], "string").toDF('gooddomains')

现在... 仅使用Spark SQL原语的天真的解决方案是稍微简化当前的方法。既然您已经声明可以安全地假定它们是有效的公共领域,那么我们可以定义如下函数:

from pyspark.sql.functions import col, regexp_extract

def suffix(c): 
    return regexp_extract(c, "([^.]+\\.[^.]+$)", 1) 

提取顶级域和第一级子域:

domains_with_suffix = (domains
    .withColumn("suffix", suffix("domains"))
    .alias("domains"))
good_domains_with_suffix = (good_domains
    .withColumn("suffix", suffix("gooddomains"))
    .alias("good_domains"))

domains_with_suffix.show()
+--------------------+--------------------+
|             domains|              suffix|
+--------------------+--------------------+
|something.google.com|          google.com|
|something.google....|               ac.uk|
|something.good.co...|              com.cy|
|something.good.co...|             mal.org|
|something.bad.com.cy|              com.cy|
|  omgalsogood.com.cy|              com.cy|
|         good.com.cy|              com.cy|
|  sogood.example.com|         example.com|
|notsoreal.googleecom|notsoreal.googleecom|
+--------------------+--------------------+

现在我们可以进行外部联接:

from pyspark.sql.functions import (
    col, concat, lit, monotonically_increasing_id, sum as sum_
)

candidates = (domains_with_suffix
    .join(
        good_domains_with_suffix,
        col("domains.suffix") == col("good_domains.suffix"), 
        "left"))

并过滤结果:

is_good_expr = (
    col("good_domains.suffix").isNotNull() &      # Match on suffix
    (

        # Exact match
        (col("domains") == col("gooddomains")) |
        # Subdomain match
        col("domains").endswith(concat(lit("."), col("gooddomains")))
    )
)

not_good_domains = (candidates
    .groupBy("domains")  # .groupBy("suffix", "domains") - see the discussion
    .agg((sum_(is_good_expr.cast("integer")) > 0).alias("any_good"))
    .filter(~col("any_good"))
    .drop("any_good"))

not_good_domains.show(truncate=False)     
+----------------------------------------+
|domains                                 |
+----------------------------------------+
|omgalsogood.com.cy                      |
|notsoreal.googleecom                    |
|something.good.com.cy.mal.org           |
|something.google.com.somethingelse.ac.uk|
|something.bad.com.cy                    |
+----------------------------------------+

这比Cartesian product required for direct join with LIKE更好,但对暴力破解并不令人满意,在最坏的情况下,需要进行两次改组-其中一次用于join(如果good_domains被忽略,则可以跳过足够小到broadcasted),另一个足够group_by + agg

不幸的是,Spark SQL不允许自定义分区程序仅对两者使用一个改组(但是RDD API中的composite key可能这样做)并且优化器还不够聪明,无法优化join(_, "key1").groupBy("key1", _)

如果您可以接受一些误报,则可以概率。首先,我们来建立概率计数器(此处在bounter的帮助下使用toolz

from pyspark.sql.functions import concat_ws, reverse, split
from bounter import bounter
from toolz.curried import identity, partition_all

# This is only for testing on toy examples, in practice use more realistic value
size_mb = 20      
chunk_size = 100

def reverse_domain(c):
    return concat_ws(".", reverse(split(c, "\\.")))

def merge(acc, xs):
    acc.update(xs)
    return acc

counter = sc.broadcast((good_domains
    .select(reverse_domain("gooddomains"))
    .rdd.flatMap(identity)
    # Chunk data into groups so we reduce the number of update calls
    .mapPartitions(partition_all(chunk_size))
    # Use tree aggregate to reduce pressure on the driver, 
    # when number of partitions is large*
    # You can use depth parameter for further tuning
    .treeAggregate(bounter(need_iteration=False, size_mb=size_mb), merge, merge)))

接下来定义这样的用户定义函数

from pyspark.sql.functions import pandas_udf, PandasUDFType
from toolz import accumulate

def is_good_counter(counter):
    def is_good_(x):
        return any(
            x in counter.value 
            for x in accumulate(lambda x, y: "{}.{}".format(x, y), x.split("."))
        )

    @pandas_udf("boolean", PandasUDFType.SCALAR)
    def _(xs):
        return xs.apply(is_good_)
    return _

并过滤domains

domains.filter(
    ~is_good_counter(counter)(reverse_domain("domains"))
).show(truncate=False)
+----------------------------------------+
|domains                                 |
+----------------------------------------+
|something.google.com.somethingelse.ac.uk|
|something.good.com.cy.mal.org           |
|something.bad.com.cy                    |
|omgalsogood.com.cy                      |
|notsoreal.googleecom                    |
+----------------------------------------+

在Scala 中,可以使用bloomFilter

import org.apache.spark.sql.Column
import org.apache.spark.sql.functions._
import org.apache.spark.util.sketch.BloomFilter

def reverseDomain(c: Column) = concat_ws(".", reverse(split(c, "\\.")))

val checker = good_domains.stat.bloomFilter(
  // Adjust values depending on the data
  reverseDomain($"gooddomains"), 1000, 0.001 
)

def isGood(checker: BloomFilter) = udf((s: String) => 
  s.split('.').toStream.scanLeft("") {
    case ("", x) => x
    case (acc, x) => s"${acc}.${x}"
}.tail.exists(checker mightContain _))


domains.filter(!isGood(checker)(reverseDomain($"domains"))).show(false)
+----------------------------------------+
|domains                                 |
+----------------------------------------+
|something.google.com.somethingelse.ac.uk|
|something.good.com.cy.mal.org           |
|something.bad.com.cy                    |
|omgalsogood.com.cy                      |
|notsoreal.googleecom                    |
+----------------------------------------+

,如果需要,请shouldn't be hard to call such code from Python

由于近似性质,这可能仍不能完全令人满意。如果您需要精确的结果,则可以尝试利用数据的冗余性质,例如使用trie(此处使用datrie实现)。

如果good_domains相对较小,则可以按照与概率变体类似的方式创建单个模型:

import string
import datrie


def seq_op(acc, x):
    acc[x] = True
    return acc

def comb_op(acc1, acc2):
    acc1.update(acc2)
    return acc1

trie = sc.broadcast((good_domains
    .select(reverse_domain("gooddomains"))
    .rdd.flatMap(identity)
    # string.printable is a bit excessive if you need standard domain
    # and not enough if you allow internationalized domain names.
    # In the latter case you'll have to adjust the `alphabet`
    # or use different implementation of trie.
    .treeAggregate(datrie.Trie(string.printable), seq_op, comb_op)))

定义用户定义函数:

def is_good_trie(trie):
    def is_good_(x):
        if not x:
            return False
        else:
            return any(
                x == match or x[len(match)] == "."
                for match in trie.value.iter_prefixes(x)
            )

    @pandas_udf("boolean", PandasUDFType.SCALAR)
    def _(xs):
        return xs.apply(is_good_)

    return _

并将其应用于数据:

domains.filter(
    ~is_good_trie(trie)(reverse_domain("domains"))
).show(truncate=False)
+----------------------------------------+
|domains                                 |
+----------------------------------------+
|something.google.com.somethingelse.ac.uk|
|something.good.com.cy.mal.org           |
|something.bad.com.cy                    |
|omgalsogood.com.cy                      |
|notsoreal.googleecom                    |
+----------------------------------------+

此特定方法的假设是可以将所有good_domains压缩为单个trie,但可以轻松扩展以处理不满足此假设的情况。例如,您可以为每个顶级域或后缀(在朴素的解决方案中定义)构建一个trie

(good_domains
    .select(suffix("gooddomains"), reverse_domain("gooddomains"))
    .rdd
    .aggregateByKey(datrie.Trie(string.printable), seq_op, comb_op))

,然后从序列化版本中按需加载模型,或使用RDD操作。

可以根据数据,业务需求(例如近似解决方案中的假负容忍度)和可用资源(驱动程序内存,执行程序内存,suffixes的基数,访问权限)进一步调整这两种非本地方法分布式POSIX兼容的分布式文件系统等)。在DataFramesRDDs上应用它们之间进行选择时,还需要权衡一些选择(内存使用,通信和序列化开销)。


*参见Understanding treeReduce() in Spark

答案 1 :(得分:4)

如果我理解正确,您只想使用简单的SQL字符串匹配模式进行左反连接。

from pyspark.sql.functions import expr

dd.alias("l")\
    .join(
        dd1.alias("r"), 
        on=expr("l.domains LIKE concat('%', r.gooddomains)"), 
        how="leftanti"
    )\
    .select("l.*")\
    .show(truncate=False)
#+----------------------------------------+
#|domains                                 |
#+----------------------------------------+
#|something.google.com.somethingelse.ac.uk|
#|something.good.com.cy.mal.org           |
#+----------------------------------------+

表达式concat('%', r.gooddomains)r.gooddomains前面加了通配符。

接下来,我们使用l.domains LIKE concat('%', r.gooddomains)查找与该模式匹配的行。

最后,指定how="leftanti"以便仅保留不匹配的行。


更新the comments@user10938362中指出,这种方法存在两个缺陷:

1)由于仅查看匹配的后缀,因此在某些情况下会产生错误的结果。例如:

  

example.com应该匹配example.comsubdomain.example.com,但不能匹配fakeexample.com

有两种方法可以解决此问题。首先是修改LIKE表达式以处理此问题。由于我们知道这些都是有效域,因此我们可以检查域的完全匹配项或点号:

like_expr = " OR ".join(
    [
        "(l.domains = r.gooddomains)",
        "(l.domains LIKE concat('%.', r.gooddomains))"
    ]
)

dd.alias("l")\
    .join(
        dd1.alias("r"), 
        on=expr(like_expr), 
        how="leftanti"
    )\
    .select("l.*")\
    .show(truncate=False)

类似地,人们可以将RLIKE与带有后视的正则表达式模式一起使用。

2)更大的问题是,如here所述,在LIKE表达式上进行联接将导致笛卡尔积。如果dd1足够小,可以广播,那么这不是问题。

否则,您可能会遇到性能问题,并且必须尝试其他方法。


有关LIKE的PySparkSQL Apache HIVE docs运算符的更多信息:

A LIKE B

  

如果字符串A与SQL简单正则表达式B匹配,则为TRUE,否则为FALSE。逐个字符进行比较。 B中的_字符匹配A中的任何字符(类似于posix正则表达式中的.),B中的%字符匹配A中任意数量的字符(类似于{ posix正则表达式中的{1}}。例如,.*评估为FALSE,而'foobar' LIKE 'foo'评估为TRUE,'foobar' LIKE 'foo___'也评估为TRUE。要转义'foobar' LIKE 'foo%',请使用%\匹配一个%字符)。如果数据包含分号,并且您要搜索它,则需要对其进行转义,%


注意:这利用了从pyspark.sql.functions.exprpass in a column value as a parameter to a function的“技巧”。