python-3.x 为了重复数据删除的目的,我如何使用一组公共键正确地散列字典?

gc0ot86w  于 2023-01-03  发布在  Python
关注(0)|答案(3)|浏览(97)

我有一些日志数据,如:

logs = [
 {'id': '1234', 'error': None, 'fruit': 'orange'},
 {'id': '12345', 'error': None, 'fruit': 'apple'}
]

每个法令都有相同的密钥:'id''error''fruit'(在本示例中)。
我想从这个列表中删除remove duplicates,但是基于dictset的简单方法不起作用,因为我的元素本身就是dict,即not hashable

>>> set(logs)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'dict'

另一种方法是sort and use itertools.groupby-但dict也不具有可比性,因此这也不起作用:

>>> from itertools import groupby
>>> [k for k, _ in groupby(sorted(logs))]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: '<' not supported between instances of 'dict' and 'dict'

我的想法是为每个日志条目计算一个散列值,并将其存储在set中进行比较,如下所示:

def compute_hash(log_dict: dict):
    return hash(log_dict.values())

def deduplicate(logs):
    already_seen = set()
    for log in logs:
        log_hash = compute_hash(log)
        if log_hash in already_seen:
            continue
        already_seen.add(log_hash)
        yield log

但是,我发现compute_hash对不同的字典给予相同的哈希值,即使是那些内容完全虚假的字典:

>>> logs = [{'id': '123', 'error': None, 'fruit': 'orange'}, {}]
>>> # The empty dict will be removed; every dict seems to get the same hash.
>>> list(deduplicate(logs))
[{'id': '123', 'error': None, 'fruit': 'orange'}]

经过一些实验,我似乎能够通过修改compute_hash来解决这个问题,如下所示:

def compute_hash(log_dict: dict):
    return hash(frozenset(log_dict.values()))

但是,我不明白为什么这会造成不同。为什么原始版本似乎对每个输入dict都给予相同的哈希值?为什么首先要将.values结果转换为frozenset来解决这个问题?除此之外:这个算法是正确的吗?或者有一些反例,错误的值将被删除?

kpbwa7wx

kpbwa7wx1#

哪里出错了

关于最初的尝试,我想指出的第一件事是它看起来过于工程化了,当输入是可哈希的,手动迭代只需要to preserve order,即使这样,在Python 3.7及更高版本中,我们可以依赖dict s的顺序保持属性。

仅仅因为它是可散列的并不意味着散列有用

log_dict.values()上调用hash也不是特别有用,虽然log_dict是不可哈希的,但它的.values()(Python 3.x中)是dict_values类型的示例(名称没有在内置函数中定义,但示例是这样标识自己的),而dict_values类型**是可哈希的:

>>> dv = {1:2, 3:4}.values()
>>> dv
dict_values([2, 4])
>>> {dv}
{dict_values([2, 4])}

因此,我们可以很容易地将.values()直接用作“散列”:

def compute_hash(log_dict: dict):
    return log_dict.values()

......但这会带来一个新的bug --现在每个散列都会不同

>>> {1:2}.values() == {1:2}.values()
False

但是为什么?

因为dict_values类型没有定义__hash__,也没有定义__eq__object是直接超类,所以对这些方法的调用会退回到object的默认值:

>>> dv.__class__.__bases__
(<class 'object'>,)
>>> dv.__class__.__hash__
<slot wrapper '__hash__' of 'object' objects>
>>> dv.__class__.__eq__
<slot wrapper '__eq__' of 'object' objects>

事实上,dict_values无法合理地实现这些方法,因为它是(间接)可变的-作为视图,它依赖于底层的dict:

>>> d = {1:2}
>>> dv = d.values()
>>> d[3] = 4
>>> dv
dict_values([2, 4])

由于没有一种明显的通用方法来散列任何对象,同时还考虑其实际属性,因此默认方法 * 不 * 考虑属性,而是简单地基于对象标识。例如,在我的平台上,结果如下所示:

Python 3.8.10 (default, Nov 14 2022, 12:59:47) 
[GCC 9.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> dv = {1:2, 3:4}.values()
>>> bin(id(dv))
'0b11111110101110011010010110000001010101011110000'
>>> bin(hash(dv))
'0b1111111010111001101001011000000101010101111'

换句话说:

>>> hash(dv) == id(dv) // 16
True

因此,如果原始代码中的compute_hash被临时对象重复调用,它不会给予有用的结果--结果不依赖于对象的内容,并且通常是相同的,因为循环中的临时(即直接GCd)对象通常会在同一个内存位置结束。
(Yes,这意味着对象默认为可散列和等价比较的,dict类型本身重写__hash__以显式禁止它,而奇怪的是重写__eq__以比较内容。)

frozenset具有有用的哈希

另一方面,frozenset是用来长期存储一些不可变的数据的,因此定义一个__hash__是非常重要和有用的,它做到了:

>>> f = frozenset(dv)
>>> bin(id(f))
'0b11111110101110011010001011101000110001011100000'
>>> bin(hash(f))
'0b101111010001101001001111100001000001100111011101101100000110001'

字典、散列和冲突检测

尽管这些年来已经有了很多调整和优化,但Python dictset类型基本上都是based on hash tables。(通常为整数值),然后将该值减小(通常使用模)转换为基础表存储的索引。类似地,当查找值时,散列被计算和简化以便确定在表中何处查找该值。
当然,也有可能在这个位置已经存储了其他值,有多种可能的策略来处理这个问题(我最近检查了一下,文献中对它们的命名并不一致),但最重要的是,对于我们的目的来说:当通过键在X1 M22 N1 X中查找值或者检查值在X1 M23 N1 X中的存在时,容器还必须在弄清楚查找的位置之后进行相等性检查,以便确认实际上已经找到了正确的东西。
因此,任何简单地手动计算散列并天真地将这些散列与原始值相关联的方法都将失败。两个输入字典很容易具有相同的计算散列值,* 即使实际上考虑了它们的内容 *。例如,frozenset的散列是基于元素散列的异或,因此如果我们的两个输入字典具有相同的值 * 以不同的顺序分配给键 *,散列将是相同的:

>>> def show_hash(d):
...     return bin(hash(frozenset(d.values())))
... 
>>> show_hash({'id': '1', 'error': None, 'value': 'apple'})
'0b101010010100001000111001000001000111101111110100010000010101110'
>>> # Changing a value changes the hash...
>>> show_hash({'id': '1', 'error': None, 'value': 'orange'})
'0b11111111001000011101011001001011100010100100010010110000100100'
>>> # but rearranging them does not:
>>> show_hash({'id': '1', 'error': 'orange', 'value': None})
'0b11111111001000011101011001001011100010100100010010110000100100'

这样的哈希冲突也有可能发生在完全不相关的值上,但对于64位的哈希值来说,这种情况几乎不可能发生(因为这个值不会被简化,也不会被用作哈希表索引,尽管它的名字是这样的)

显式修复

因此,为了得到正确的代码,我们需要在之后做自己的检查,显式地检查散列到already_seen集合中的某个值是否等于之前使用该散列的值,理论上可能有多个这样的值,所以我们需要为每个外部散列记住多个值,也许可以用dict代替already_seen

from collections import defaultdict

def deduplicate(logs):
    already_seen = defaultdict(list)
    for log in logs:
        log_hash = compute_hash(log)
        if log in already_seen.get(log_hash, ()):
            continue
        already_seen[log_hash].append(log)
        yield log

希望这看起来并不令人满意。使用这种方法,我们基本上重新实现了集合和字典的核心逻辑-我们自己计算哈希,从内部存储器(already_seen中检索相应的值,然后手动检查是否相等(if log in ...)。

从另一个Angular 来看

我们之所以要做这一切--在我们自己的存储中寻找一个哈希值来表示原始的dict--是因为dict是不可哈希的,如果我们直接解决这个问题呢?

也就是说:与其简单地从碰巧是可哈希的原始数据中计算一个值,不如显式地将数据转换为可哈希形式,保留所有信息。换句话说,使用不同的类型来表示数据,而不是dict
由于我们所有的输入dict都有相同的键,所以很自然的事情就是将它们转换成用户定义类属性。在Python 3.7及以上版本中,一个简单、自然和明确的方法是使用dataclass,如下所示:

from dataclasses import dataclass
from typing import Optional

@dataclass(frozen=True, slots=True)
class LogEntry:
    id: str
    error: Optional[str]
    fruit: str

文档中没有很好地解释,但是使用frozen=True(主要目的是使示例不可变)也会生成__hash__,根据需要考虑字段;使用slots=True也会为类型avoiding memory overhead生成__slots__
从这里开始,转换现有日志就很简单了:

logs = [LogEntry(**d) for d in logs]

我们可以使用set直接进行重复数据消除:

set(logs)

或者,使用dict保留顺序(在3.7及更高版本中):

list(dict.fromkeys(logs))

当然,还有其他的选择,最简单的是从.values生成一个tuple--假设每个日志dict都有其键 * 以相同的顺序 *(同样,假设Python 3.7及更高版本,其中键 * 有 * 一个顺序),这保留了所有 * 有用 * 的信息--.keys只是为了方便。我们可以使用collections.namedtuple

from collections import namedtuple

LogEntry = namedtuple('LogEntry', 'id error fruit')
# from here, use the LogEntry type as before

这比dataclass方法简单,但不太明确(并且没有提供一种优雅的方式来记录字段类型)。

v1l68za4

v1l68za42#

你有一些可行的答案,但我认为你可能把事情弄得过于复杂了。下面是我会在你的原始代码上做的快速修复。

logs = [
    {'id': '1234', 'error': None, 'fruit': 'orange'},
    {'id': '1234', 'error': None, 'fruit': 'orange'},
    {'id': '12345', 'error': None, 'fruit': 'apple'}, 
]

def get_values(log: dict):
    return tuple(log.values())

unique_logs = set(map(get_values, logs))
for log in unique_logs:
    print(log)

(“12345”、无、“苹果”)
(“1234”、无、“橙色”)

ncgqoxb0

ncgqoxb03#

1.你不能仅仅根据散列来判断"已经看到"。散列比实际数据小,但可能会有冲突,这是一种权衡。使用散列对日志进行分组,然后检查是否相等。
1.仍然会有碰撞。

  1. dict已经通过hash对键进行分组并检查是否相等,不需要重新创建这个。您的日志是字典,因为它们是可变的,所以无法散列。一个简单的方法是使用json.dumps()将您的dict转换为字符串。或者为了更有效地存储,找到类似于frozenset但针对dict的方法。
already_seen = set()
for log in logs:
    log_hash = json.dumps(log, sort_keys=True)
    if log_hash in already_seen:
        continue
    already_seen.add(log_hash)

相关问题