Как найти минимальный набор объектов, удовлетворяющих нескольким условиям с вложенными атрибутами в Python?

Я работаю над проблемой, в которой мне нужно сгенерировать минимальный набор объектов, удовлетворяющих заданному списку условий. Условия включают вложенные атрибуты, и для каждого атрибута у меня есть предопределенный список возможных значений.

Я хочу сгенерировать минимальный набор объектов, удовлетворяющих всем условиям. Каждый объект должен быть построен с использованием возможных значений атрибутов.

Для входных данных:

attribute_values = {
    "object.object.value": ["value1", "value2", "value3"],
    "object.other.value": ["value4", "value5", "value6"],
    "object.another.value": [10, 20, 30]
}


conditions = [
    {"path": "object.object.value", "operator": "==", "value": "value1"},  # Condition 1
    {"path": "object.other.value", "operator": "in", "value": ["value5", "value6"]},  # Condition 2
    {"path": "object.another.value", "operator": ">", "value": 15},  # Condition 3
    {"path": "object.object.value", "operator": "==", "value": "value2", 
     "path2": "object.another.value", "operator2": "<", "value2": 25}  # Condition 4
]

На выходе должен получиться минимальный набор объектов, например:

[
{
    "object": {
        "object": {
            "value": "value1"
        },
        "other": {
            "value": "value5"
        },
        "another": {
            "value": 20
        }
    }
},
{
    "object": {
        "object": {
            "value": "value2"
        },
        "other": {
            "value": "value6"
        },
        "another": {
            "value": 10
        }
    }
}
]

Этот вывод удовлетворяет всем условиям:

"object.object.value" == "value1"   # Condition 1
"object.other.value" in ["value5", "value6"]   # Condition 2
"object.another.value" > 15   # Condition 3
"object.object.value" == "value2" AND "object.another.value" < 25   # Condition 4

Количество возможных значений атрибутов может варьироваться в пределах 3-15.

Мне нужен «минимальный» набор объектов, потому что в дальнейшем они потребуют ручных операций, а при всех возможных комбинациях значений это станет практически невозможным. Это также будет некоторым излишеством.


Ответы (3 шт):

Автор решения: Vova

Насколько понимаю, вы делаете свою ORM. Я бы посоветовал рассмотреть использование Pydantic — можно добавить фильтры и встроить логику прямо в модель. Альтернатива — обычный @dataclass с обработкой фильтрации через __post_init__. Оба подхода дадут более читаемый и устойчивый код по сравнению с манипуляциями напрямую с dict.

P.S. Пишу здесь, потому что пока не могу оставить комментарий — не хватает репутации =(

→ Ссылка
Автор решения: Vitalizzare ушел в монастырь

Это скорее рассуждения, а не ответ. Используемый код не тестировался.

Если я правильно понял, вы работаете с объектами, у которых фиксированный набор атрибутов. Каждый атрибут может принимать конечное наперед заданное количество значений. Также вам задан перечень независимых наборов ограничений по тем или иным атрибутам. Перед вами стоит задача собрать минимальное множество объектов, в котором для каждого набора ограничений найдется хотя бы один удовлетворяющий им объект.

Дальше я буду исходить из следующего представления о нотации заданных параметров:

attribute_values = {
    "object.object.value": ["value1", "value2", "value3"],
    "object.other.value": ["value4", "value5", "value6"],
    "object.another.value": [10, 20, 30]
}

conditions = [
    # Condition 1
    [{"path": "object.object.value", "operator": "==", "value": "value1"}],  
    # Condition 2
    [{"path": "object.other.value", "operator": "in", "value": ["value5", "value6"]}],  
    # Condition 3
    [{"path": "object.another.value", "operator": ">", "value": 15}],  
    # Condition 4
    [{"path": "object.object.value", "operator": "==", "value": "value2"}, 
     {"path": "object.another.value", "operator": "<", "value": 25}]  
]

Здесь я позволил себе изменить запись условий, чтобы каждое представляло собой словарь с ключами ('path', 'operator', 'value'). Мы можем использовать это в функции, которая будет возвращать перечень допустимых значений для каждого атомарного условия:

def get_allowed[T](path: str, op: str, value: T | list[T],
                   attribute_values: dict[str, Iterable]) -> set[T]:
    if op == '==':
        return {value}
    elif op == 'in':
        return set(value)
    from operator import lt, le, gt, ge
    condition = (lt if op == '<' else 
                 le if op == '<=' else 
                 gt if op == '>' else 
                 ge if op == '>=' else 
                 None)
    if condition is None:
        raise ValueError(f'Unknown operator: {op!r}')
    return {x for x in attribute_values[path] if condition(x, value)}

Дальше, мы могли бы выбрать "подмножества" объектов в виде collections.ChainMap из двух словарей, где первый - это ключи с ограничениями, а второй - значения по умолчанию для обращения к ключам без ограничений (подмножества взяты в кавычки, потому что это перечень допустимых значений по каждому ключу, из которых могут составляться объекты подмножества, т.е. мы лишь обозначаем границы подмножества по каждому атрибуту):

from collections import ChainMap

def get_sets(attribute_values: dict[str, list], conditions: list[list[dict]]) -> list[ChainMap]:
    attributes = {k: set(v) for k, v in attribute_values.items()}
    sets = []
    for cond in conditions:
        restricted = ChainMap({}, attributes)
        for item in cond:
            allowed = get_allowed(item['path'], item['operator'], item['value'], attributes)
            restricted[item['path']] = allowed & restricted[item['path']]
        sets.append(restricted)
    return sets

Теперь мы могли бы строить искомое множество, удовлетворяющее заданным ограничениям, постепенно пересекая подмножества из отдельных ограничений, пока пересечения не иссякнут:

Attributes = ChainMap[str, set]

def get_intersection(x: Attributes, y: Attributes) -> Attributes:
    intersection = ChainMap({}, x.maps[1])
    keys = x.maps[0].keys() | y.maps[0].keys()
    for k in keys:
        intersection[k] = common_items = x[k] & y[k]
        if not common_items:
            return {}
    return intersection

def get_conditioned_subset(attribute_values, conditions):
    sets = get_sets(attribute_values, conditions)
    answer = []
    while sets:
        remained_sets = []
        subset = sets.pop()
        for s in sets:
            intersection = get_intersection(subset, s)
            if not intersection:
                remained_sets.append(s)
            else:
                subset = intersection
        answer.append({key: next(iter(values)) for key, values in subset.items()})
        sets = remained_sets
    return answer

answer = get_conditioned_subset(attribute_values, conditions)

Я не уверен, будет ли это минимальное множество объектов, покрывающее заданные условия. Но наверное это можно было бы использовать как первое приближение к ответу. Как минимум, для случая пересекающихся условий этот подход вернет набор из одного объекта, а для попарно пересекающихся условий количество объектов будет не более половины количества условий.

→ Ссылка
Автор решения: Vladislav Dyshlyuk

Немного отредактировав ответ @vitalizzare для меня сработало следующее решение

from typing import ChainMap
from operator import lt, le, gt, ge, ne

attribute_values = {
    "object.object.value": ["value1", "value2", "value3"],
    "object.other.value": ["value4", "value5", "value6"],
    "object.another.value": [10, 20, 30]
}

conditions = [
    # Condition 1
    [{"path": "object.object.value", "operator": "==", "value": "value1"}],  
    # Condition 2
    [{"path": "object.other.value", "operator": "in", "value": ["value5", "value6"]}],  
    # Condition 3
    [{"path": "object.another.value", "operator": ">", "value": 15}],  
    # Condition 4
    [{"path": "object.object.value", "operator": "==", "value": "value2"}, 
     {"path": "object.another.value", "operator": "<", "value": 25}]  
]

T = TypeVar('T')

def get_allowed(path: str, op: str, value: Union[T, List[T]],
    attribute_values: Dict[str, Iterable]) -> Set[T]:
        if op == '==':
            return {value}
        elif op == 'in':
            return set(value)
        from operator import lt, le, gt, ge
        condition = (lt if op == '<' else 
                     le if op == '<=' else 
                     gt if op == '>' else 
                     ge if op == '>=' else 
                     ne if op == '!=' else
                     None)
        if condition is None:
            raise ValueError(f'Unknown operator: {op!r}')
        return {x for x in attribute_values[path] if condition(x, value)}

from collections import ChainMap as CollectionsChainMap

def get_sets(attribute_values: Dict[str, List[T]], conditions: List[List[Dict]]) -> List[CollectionsChainMap]:
    attributes = {k: set(v) for k, v in attribute_values.items()}
    sets = []
    for cond in conditions:
        restricted = CollectionsChainMap({}, attributes)
        for item in cond:
            allowed = get_allowed(item['path'], item['operator'], item['value'], attributes)
            restricted[item['path']] = allowed & restricted[item['path']]
        sets.append(restricted)
    return sets

Attributes = ChainMap[str, set]

def get_intersection(x: Attributes, y: Attributes) -> Attributes:
    intersection = {}
    keys = set(x.keys()) & set(y.keys())
    for k in keys:
        intersection[k] = x[k] & y[k]
        if not intersection[k]:
            return {}
    return intersection


def get_conditioned_subset(attribute_values, conditions):
    sets = get_sets(attribute_values, conditions)
    answer = []
    while sets:
        remained_sets = []
        subset = sets.pop()
        for s in sets:
            intersection = get_intersection(subset, s)
            if not intersection:
                remained_sets.append(s)
            else:
                subset = intersection
        try:
            answer.append({key: next(iter(values)) for key, values in subset.items()}) 
        except StopIteration:
            break
        sets = remained_sets
    return answer


answer = get_conditioned_subset(attribute_values, conditions)
→ Ссылка