
Привет, Хабр! Меня зовут Андрей, я старший инженер по разработке ПО в YADRO. В прошлом квартале для коммутаторов KORNFELD мы разрабатывали функциональность Auto-Negotiation (автосогласование скорости, режима и других параметров передачи данных) и FEC (коррекцию битовых ошибок) из стандарта IEEE 802.3. Нам нужно было в реальном времени при горячей замене трансиверов определить их тип и тип среды передачи данных. Задача как будто простая: почему бы не заглянуть в EEPROM? Но есть нюансы.
С трансиверами от YADRO все просто: на них наш логотип и серийник, они совместимые, проходят многомесячную валидацию, поэтому в EEPROM все четко. Вот только в реальной жизни клиент не всегда использует такие устройства — и что будет указано в их EEPROM, зависит от добросовестности и фантазии производителя. Если вы купите два «одинаковых» трансивера в Китае с абсолютно идентичными наклейками, у вас есть все шансы обнаружить в EEPROM две разные начинки. На всякий отмечу, что применение сторонних устройств — это всегда риск для работоспособности системы, и это стоит учитывать.
Через нас прошло множество вариантов трансиверов, и мы научились быстро добывать из них нужную нам информацию (те еще сыщики!). В статье покажу способ, как определить тип трансивера и тип среды передачи данных при помощи одной теории и как получить воспроизводимый результат на Python. Материал будет полезен инженерам и программистам, которым нужно принимать решения на основе неполных и противоречивых входных данных. Приступим!
Присоединяйтесь к команде — сейчас в YADRO открыты вакансии:
Вся инфа есть в EEPROM трансивера, но это не точно
Для современных сетевых устройств автоматизация настройки линков — стандартная практика. Скорости, режимы автосогласования, типы FEC и другие параметры подбираются «на лету», без участия человека. В случае Leaf/Spine-коммутаторов речь идет о работе с портами — физически это слоты для SFP/QSFP-трансиверов, допускающие горячую замену модулей. Чтобы механизмы Auto-Negotiation и FEC работали корректно, системе нужно знать свойства трансивера, установленного в такой порт: медный это линк или оптический, кабельная это сборка или разъемный трансивер, пассивный DAC, активный AEC или AOC. От этого напрямую зависит, нужно ли вообще включать автосогласование, какие скорости и режимы FEC анонсировать соседнему устройству и на какой стороне — ASIC или самом трансивере — должны корректироваться ошибки.
Казалось бы, вся эта информация должна однозначно читаться из EEPROM трансивера. Но, как это часто бывает, на практике все сложнее. Разные производители по-разному заполняют стандартные поля: часть данных может неоднозначно или откровенно противоречить другим полям или ее может не быть вообще. В таких условиях попытка принять решение на основе одного-двух параметров быстро превращается в набор эвристик и исключений, а они плохо масштабируются и ломаются при первом же нестандартном модуле.
Именно с этим мы столкнулись, когда разрабатывали функциональность автоматического выбора режимов автосогласования и FEC. Нам нужно было в реальном времени при горячей замене трансиверов получать максимально корректное представление о типе среды передачи данных и типе трансивера, причем делать это устойчиво к неполным и противоречивым данным. Покажу, как мы решали эту задачу для коммутаторов KORNFELD D1156/D2132.
Теория Демпстера-Шафера и рыжие котики
Постановка задачи кажется простой. У нас есть набор параметров, которые можно прочитать из EEPROM трансивера. Каждый из них прямо или косвенно намекает на то, к какой группе устройство относится — медь или оптика, DAC или AOC, активный или пассивный кабель. Значит, нам нужно использовать метод, который позволит комбинировать эти данные и сделать какой-то вывод.
Сложность заключается в самих данных — точнее, в их надежности. Где-то в параллельной вселенной EEPROM трансивера — это паспорт гражданина, где каждое поле заполнено и является de-facto точной информацией. В нашей же реальности это, скорее, свидетельские показания: что-то сказал уверенно, что-то забыл, что-то перепутал, а где-то просто привел случайные цифры. Другими словами, некоторые вендоры ответственно относятся к заполнению статической информации в регистрах EEPROM трансивера, а другие — нет. Вы можете наткнуться на трансивер, в котором часть полей может быть не заполнена, и это только полбеды. А бывает, два поля заполнены так, что они прямо противоречат друг другу.
В итоге нам нужно, чтобы наш метод позволял нам:
Учитывать ценность параметров. Многие параметры прямо или косвенно свидетельствуют о принадлежности трансивера к интересующей нас группе, но не у всех них одинаковая ценность. Например, тип коннектора может многое сообщить о среде передачи данных, а вот длина кабеля слабо коррелирует с интересующей нас информацией. При этом она все равно остается валидным источником информации.
Работать с противоречивыми параметрами. Мы должны уметь обрабатывать ситуации, в которых есть прямое противоречие и получать какой-либо детерминированный ответ.
Работать с неверно заполненным параметром. То есть наличие некорректно заполненного поля не должно приводить нас в ступор.
Обрабатывать параметры с частичным пониманием. В ситуациях, когда параметр не дает нам 100% понимания, мы не должны его отвергать. Наша цель — извлечь максимум информации, которую он в себе несет. Давайте вернемся к примеру с длиной кабеля: если поле сообщает нам «5 метров», мы не сможем понять конкретный тип трансивера, но сделаем вывод, что речь, скорее всего, идет про кабельную сборку. А эта информация уже важна, тем более в ситуации, когда мы будем накапливать наши знания из других полей.
Анализ постановки задачи и формирования требований к методу ее решения плавно привели меня к теории Демпстера-Шафера. Ее еще могут называть «теория свидетельств» и «теория очевидностей». Классический фундаментальный труд по этой теме — монография Шафера, развивающая идеи Демпстера.
Теория Демпстера-Шафера (DST) — это одновременно теория свидетельств и теория вероятностного рассуждения. Она работает с «весами» доказательств и числовыми степенями поддержки, которые различные источники дают некоторому утверждению. В отличие от классических подходов, где главное — назначить число уверенности вручную, эта теория концентрируется на другом: как объединить степени доверия, полученные из разных и независимых источников данных.
В каком-то смысле это математическая модель работы следователя или судьи. У нас есть несколько свидетелей, и каждый говорит свое:
свидетель № 1 уверяет, что «котик был рыжим»;
свидетель № 2 настаивает, что «котик был черным»;
свидетель № 3 сообщает, что «котик был собакой».

Как из разрозненных и противоречивых показаний получить один согласованный вывод? И, главное, как учесть степень уверенности каждого свидетеля и его надежность? Именно такую задачу и решает теория свидетельств (наш судья или следователь): она формально описывает, как объединять частичные, неполные и конфликтующие данные в единое взвешенное решение.
Ключевой элемент теории — правило Демпстера, которое формально описывает процесс такого объединения свидетельств, особенно когда данные частичные, неоднозначные или даже противоречивые. Но прежде чем продолжить говорить о нем, стоит разобраться, что из себя представляют из себя свидетельства и базовые элементы.
Свидетельства — это математические множества. В рамках теории Демпстера-Шафера их можно разделить на четыре группы:
Согласованные (consonant) — набор вложенных друг в друга множеств, где каждое подмножество уточняет объем возможных вариантов.
Совместимые (consistent) — множества, в которых хотя бы один элемент присутствует во всех наборах.
Произвольные (arbitrary) — набор множеств, где нет ни одного элемента, общего для всех. Но есть общие элементы для некоторых множеств.
Несвязанные (disjoint) — набор множеств, в которых ни у одного множества нет общих элементов ни с одним другим множеством из набора.
Воспринять эту информацию помогут схемы:

Разный набор свидетельств и их возможные комбинации приводят к логичному вопросу: «Как выразить степень уверенности в каждом из них?». И на это есть логичный (даже очевидный) ответ: добавить к каждому набору свидетельств степень уверенности в нем.
Давайте снова на примере с котом:
{рыжий кот} — в этом свидетельстве мы уверены на 30%;
{рыжий кот, рыжая собака} — в этом свидетельстве мы уверены на 50%;
{рыжий кот, рыжая собака, черный кот} — в этом мы уверены на 20%.
И вот мы плавно подошли к понятию функции базового распределения вероятностей (BPA, Basic Probability Assignment). В некоторых источниках можно встретить название BBA (Basic Belief Assignments). BPA принято обозначать как m(X), это фундаментальный элемент теории Демпстера–Шафера. Функция BPA не пытается назначить вероятность отдельному событию, вместо этого она распределяет степени уверенности по множествам событий. Значение m(A) показывает, какая часть всей доступной информации поддерживает именно множество A, но не утверждает, как эта уверенность распределена внутри A по его элементам.
Возвращаясь к примеру выше, наши свидетельства станут такими BPA:
m({рыжий кот}) = 0.3
m({рыжий кот, рыжая собака}) = 0.5
m({рыжий кот, рыжая собака, черный кот}) = 0.2
Выражение m({рыжий кот, рыжая собака}) = 0.5 не значит, что вероятность «рыжий кот» и «рыжая собака» равномерно распределены по 0.25. Это значит, мы оцениваем степень уверенности в пользу того, что истина находится в этом множестве, как 50%.
BPA превращает «сырые» наборы свидетельств в квантифицированные. Теперь мы можем говорить о том, какая часть информации поддерживает то или иное множество.
На основе свидетельств мы можем сформировать не только BPA, но и рамку рассуждений (Frame of Discernment) — конечное множество всех возможных вариантов (гипотез), между которыми мы выбираем. В нашем маленьком примере про животных рамка будет такой: Ω = {рыжий кот, рыжая собака, черный кот}. Это пространство, внутри которого (и только внутри которого) мы можем распределять уверенность. Никаких других животных, скрытых вариантов или дополнительных гипотез вне Ω не допускается.
В теории Демпстера–Шафера есть и другие любопытные понятия. Чтобы эффективно работать с неопределенностью, DST вводит две меры — функцию доверия и функцию правдоподобия, которые описывают нижнюю и верхнюю границы нашей уверенности.
Функция доверия Bel(A) показывает минимальную гарантию того, что истинное состояние находится во множестве A. Она считает только те BPA, которые полностью содержатся внутри A. То есть Bel — это то, чему мы можем железно доверять. Чт��бы ее вычислить, мы суммируем все BPA тех множеств, которые полностью лежат внутри A. Получается, Bel(A) опирается только на твердые и безусловные свидетельства.
Функция правдоподобия Pl(A) показывает максимально возможную уверенность, которую допускают данные. Она суммирует BPA всех множеств, которые хотя бы пересекаются с A — то есть не противоречат ей. Pl — это верхняя оценка: то, что может быть правдой, если трактовать неопределенность в пользу A.
Допустим, мы хотим уточнить «уверенность», с которой можем судить о свидетельстве {рыжий кот, рыжая собака}. Для удобства обозначим это множество буквой «А». Подмножествами A будут множества {рыжий кот} и {рыжий кот, рыжая собака}, соответственно:
В нашем примере все множества пересекаются с А. Значит:
Этот результат можно трактовать так: мы минимум на 80% уверены, что истина находится во множестве А. При этом допускаем, что истинное состояние может принадлежать A с максимальной возможной уверенностью (100%), которую позволяют данные.
Подсчитывать эти функций вовсе не обязательно для того, чтобы сформировать суждение о гипотезе в рамках DST. Но, как уже упоминалось раньше, это позволяет более точно выстраивать уверенность в суждении, которое мы получаем в отношении гипотезы:
высокое,
высокое — все признаки указывают на корректность суждения.
низкое,
высокое — слишком высокая неопределенность, нехватка данных для формирования мнения о корректности суждения.
низкое,
низкое — явное противоречие, суждение некорректно.
Что ж, мы с вами познакомились со всеми необходимыми базовыми понятиями и можем вернуться к правилу Демпстера. Вот оно:
Правило Демпстера умеет две вещи: складывать согласующиеся части свидетельств и нормализовывать результат, «убирая» конфликт.
В числителе формулы (1) мы перемножаем и суммируем вероятности всех множеств свидетельств, в которых есть пересечения — то есть согласованные, совместимые и произвольные свидетельства.
Свидетель № 1 сказал: «Это рыжий кот» (0.3). Свидетель № 2: «Это был рыжий кот или рыжая собака» (0.5). Но оба согласны, что кот — возможен. Значит, вклад в m12({кот}) = 0.3 * 0.5.
K — это масса конфликта, сумма произведений BPA всех пар множеств, пересечение которых пусто. Свидетель 1 сказал: «точно рыжий кот» (0.3), свидетель 2 сказал: «точно рыжая собака» (0.4) — пересечений нет. Вклад в K = 0.3 * 0.4.
Знаменатель формулы (1) 1−K — это нормализация. Если К стремится к 0, то знаменатель формулы стремится к 1 — итог почти равен числителю (отсутствие противоречий). Если К большой, то источники противоречат друг другу, знаменатель получается маленький, тем самым увеличивая согласие между согласующимися вариантами. Если К = 1 — у нас полное противоречие и правило Демпстера не определено.
Отмечу, что нормализация правила Демпстера при сильном конфликте между источниками информации может давать контринтуитивные и некорректные результаты. Поэтому и разработаны различные модификации и альтернативные операции объединения для обработки случаев значительного конфликта. В этой статье мы не будем в них разбираться, но знайте, что они есть. А теперь перейдем с математического на простой человеческий язык.
Правило Демпстера делает понятную вещь: берет два независимых свидетельства и выделяет все, в чем они согласуются, пусть даже частично.
Это и есть та самая «общая часть», на которой можно строить вывод. Все, что противоречит друг другу, правило просто выбрасывает как недостоверное — это конфликт, который не позволяет опереться на такие данные. После того как конфликт отброшен, оставшаяся уверенность «нормализуется», то есть перераспределяется только между согласующимися вариантами, усиливая их вес. В человеческом понимании это выглядит так: если два источника хоть в чем-то сходятся, именно эта точка согласия становится основой вывода. Независимо от того, насколько сильно они расходятся в остальном.
Так что там с трансивером?
Хватит теории. Давайте перейдем к практике и посмотрим, как DST можно применить к реальной задаче. Напомню, нам нужно определять у трансивера два параметра:
среду распространения данных;
тип трансивера — кабельная сборка или разъемный модуль.
Мы будем опираться на две рамки рассуждения: для типа кабельной сборки и для среды передачи данных.
Первая рамка описывает, чем именно является трансивер — кабельной сборкой (AOC, DAC, AEC, ACC) или разъемным модулем (Separable). В коде зафиксируем это в виде множества OMEGA_CABLE_TYPES = {"AOC", "DAC", "AEC", "ACC", "Separable"}.
Вторая рамка описывает физическую среду: медь или оптика, OMEGA_MEDIA_TYPES = {"copper", "fiber"}. Любая BBA (BPA), которую я строю, всегда задается как распределение масс по подмножествам этих рамок. На основе этих рамок сразу зададим «массу полного незнания», т.е. BBA, который будет возвращаться, когда мы не можем сделать никаких конкретизированных суждений по полученному параметру: CABLE_TYPES_UNKNOWN_MASS, MEDIA_TYPES_UNKNOWN_MASS.
# hypoteses OMEGA_CABLE_TYPES = frozenset({"AOC", "DAC", "AEC", "ACC", "Separable"}) OMEGA_MEDIA_TYPES = frozenset({"copper", "fiber"}) CABLE_TYPES_UNKNOWN_MASS = {OMEGA_CABLE_TYPES: 1.0} MEDIA_TYPES_UNKNOWN_MASS = {OMEGA_MEDIA_TYPES: 1.0}
Дальше каждое доступное поле из EEPROM трансивера я рассматриваю как отдельный источник свидетельств. Для каждого такого источника я заранее задаю таблицу соответствий «значение поля → bba».
vendor_pn_aoc_bba: BBABaseDict = {True: {frozenset(("AOC",)): 0.7, OMEGA_CABLE_TYPES: 0.3}} vendor_pn_dac_bba: BBABaseDict = {True: {frozenset({"DAC", "AEC", "ACC"}): 0.7, OMEGA_CABLE_TYPES: 0.3}} def get_vendor_pn_mass(vendor_pn: str) -> BBAdict: return ( vendor_pn_aoc_bba.get("aoc" in vendor_pn) or vendor_pn_dac_bba.get("dac" in vendor_pn) or CABLE_TYPES_UNKNOWN_MASS )
Например, по полю Vendor Part Number (а это просто строка) можно получить грубое предположение о типе сборки. Полученное значение из EEPROM я передаю в функцию get_vendor_pn_mass, которая реализует простую логику: если в строке есть подстрока «AOC», тогда мы возвращаем один BBA, если есть подстрока «DAC» — другой. В крайнем случае возвращаем BBA, который сообщает: «Это поле никакой информации нам не дало».
В vendor_pn_aoc_bba я задаю {AOC}: 0.7 и OMEGA_CABLE_TYPES: 0.3. Это значит, что на основании только факта наличия определенной подстроки в поле Vendor Part Number я готов поддержать гипотезу AOC с массой 0.7, но оставляю 0.3 на полную неопределенность внутри всей рамки. Мы не требуем, чтобы EEPROM был заполнен идеально и закладываем запас на ошибки, нестандартные модули и отсутствие ценной информации. Аналогично, vendor_pn_dac_bba при совпадении с DAC-артикулами выдает массу не на один тип, а на подмножество {"DAC", "AEC", "ACC"} с 0.7 и OMEGA_CABLE_TYPES с 0.3: по одному только PN мы еще не отличаем пассивный DAC от активного AEC/ACC, но уверены, что это медная сборка, а не AOC.
Следующий пример — BBA по типу коннектора:
connector_type_bba: BBAExtendedDict = { "Copper Pigtail": { "cable_type": {frozenset(("DAC", "AEC", "ACC")): 0.95, OMEGA_CABLE_TYPES: 0.05}, "media_type": {frozenset(("copper",)): 0.95, OMEGA_MEDIA_TYPES: 0.05}, }, "No separable connector": { "cable_type": {frozenset(("DAC", "AEC", "ACC")): 0.8, frozenset(("Separable",)): 0.15, OMEGA_CABLE_TYPES: 0.05}, "media_type": {frozenset(("copper",)): 0.8, frozenset(("fiber",)): 0.15, OMEGA_MEDIA_TYPES: 0.05}, }, "Optical Pigtail": { "cable_type": {frozenset(("AOC",)): 0.95, OMEGA_CABLE_TYPES: 0.05}, "media_type": {frozenset(("fiber",)): 0.95, OMEGA_MEDIA_TYPES: 0.05}, }, "RJ45": { "cable_type": {frozenset(("DAC",)): 0.1, frozenset(("Separable",)): 0.85, OMEGA_CABLE_TYPES: 0.05}, "media_type": {frozenset(("copper",)): 0.95, OMEGA_MEDIA_TYPES: 0.05}, }, "MXC 2x16": { "cable_type": {frozenset(("AOC",)): 0.2, frozenset(("Separable",)): 0.75, OMEGA_CABLE_TYPES: 0.05}, "media_type": {frozenset(("fiber",)): 0.95, OMEGA_MEDIA_TYPES: 0.05}, }, "MPO 2x16": { "cable_type": {frozenset(("AOC",)): 0.2, frozenset(("Separable",)): 0.75, OMEGA_CABLE_TYPES: 0.05}, "media_type": {frozenset(("fiber",)): 0.95, OMEGA_MEDIA_TYPES: 0.05}, }, "LC": { "cable_type": {frozenset(("AOC",)): 0.2, frozenset(("Separable",)): 0.75, OMEGA_CABLE_TYPES: 0.05}, "media_type": {frozenset(("fiber",)): 0.95, OMEGA_MEDIA_TYPES: 0.05}, }, }
В маппинге connector_type_bba я храню сразу два набора свидетельств: для типа кабеля (cable_type) и для среды (media_type). Например, для Copper Pigtail я задаю: для cable_type — {DAC, AEC, ACC}: 0.95 и OMEGA_CABLE_TYPES: 0.05; для media_type — {"copper"}: 0.95 и OMEGA_MEDIA_TYPES: 0.05. Это значит, что разъем типа Copper Pigtail почти однозначно указывает на медную кабельную сборку (DAC/AEC/ACC) и «медную» среду передач данных. Но небольшую массу (0.05) я снова оставляю на случай некорректно заполненного поля.
Для разъемов LC/MPO/MXC, наоборот, основная масса уходит в пользу AOC/Separable и fiber. Отдельно обрабатываются случаи, где разъем выглядит как «No separable connector» или RJ45: здесь BBA уже заведомо более размазанная. Заметная часть массы попадает на Separable, ведь физическая форма фактически указывает на наличие разъемного трансивера, а не только на кабельную сборку.
Если посмотреть на приведенные примеры BBA, закономерно возникнет вопрос: по какому принципу вообще задаются массы доверия тем или иным гипотезам? Универсального ответа здесь нет. В нашем случае значения масс подбирались эмпирически — на основе статистики по реальным моделям трансиверов, прошедших через систему. Те поля EEPROM, которые на практике чаще всего оказывались корректно заполненными и действительно коррелировали с реальным типом модуля, получали большую массу доверия. Напротив, поля, которые производители заполняют нерегулярно, неоднозначно или с явными ошибками, намеренно получали меньший вес. Таким образом, BBA отражают не абстрактную «веру» в поле стандарта, а его фактическую надежность в реальных условиях эксплуатации.
Описав различные BBA, я упаковываю их в маппинг (словарь). Для BBA, которые я описал выше, будет так:
sfp_bba_pack: BBAPackDict = { "VENDOR_PN": get_vendor_pn_mass, "CONNECTOR": connector_type_bba, }
Строго говоря, упаковывать все это в словарь в рамках примера необходимости нет, но наша система поддерживает разные типы трансиверов с разными спецификациями системы управления: SFF-8472, SFF-8636, CMIS. У них отличается перечень полей и их содержание. Поэтому для системы управления соответствующей спецификации определяется свой словарь:
sfp_bba_pack: BBAPackDict = {...} qsfp_bba_pack: BBAPackDict = {...} cmis_bba_pack: BBAPackDict = {...}
Дальше начинается самая механическая, но при этом ключевая часть: нужно взять набор BBA от разных источников и объединить их в одно итоговое распределение масс. За это отвечает функция combine_masses — это прямая реализация правила Демпстера для двух BBA.
def combine_masses(m1: BBAdict, m2: BBAdict, fallback: frozenset[str] = frozenset({"Separable"})) -> BBAdict: """Combines two belief assignments using Dempster's rule with a fallback in case of total conflict.""" combined: defaultdict[frozenset[str], float] = defaultdict(float) conflict_mass = 0.0 for a_set, mass_a in m1.items(): for b_set, mass_b in m2.items(): intersect = frozenset(a_set & b_set) if intersect: combined[intersect] += mass_a * mass_b else: conflict_mass += mass_a * mass_b if conflict_mass == 1.0: return {fallback: 1.0} # Normalization normalization = 1.0 - conflict_mass for k in combined: combined[k] /= normalization return dict(combined)
На вход функция получает два словаря вида {frozenset(hypotheses): mass}. Дальше я перебираю все пары множеств из m1 и m2. Для каждой пары вычисляю пересечение: intersect = a_set & b_set. Если пересечение непустое, значит, эти два свидетельства совместимы. Их вклад в итоговое распределение накапливается в combined[intersect] как произведение mass_a mass_b. Именно так реализуется логика числителя формулы: мы рассматриваем все пары показаний, ищем их «общую часть» и суммируем вклад в это пересечение. Если же пересечение пустое, это означает прямое противоречие (конфликт). Тогда произведение mass_a mass_b добавляется в conflict_mass — это та самая масса конфликта K.
После прохода по всем парам возможны два случая. Если conflict_mass равна 1.0, это означает тотальный конфликт: ни одна комбинация множеств не пересекается, правило Демпстера в классическом виде в такой ситуации не определено (деление на ноль). В реальной системе с этим все равно нужно что-то делать, поэтому я ввел инженерный fallback: возвращаю распределение {fallback: 1.0}. Исходя из того, что мы пытаемся определить, в функцию будет передаваться соответствующий fallback. Если конфликт не полный, то есть conflict_mass < 1.0, происходит нормализация. По правилу Демпстера вся масса конфликта K «выбрасывается», а оставшаяся распределяется между согласующимися пересечениями так, чтобы в сумме снова получилось 1. В коде это реализовано через normalization = 1.0 - conflict_mass и деление каждого значения combined[k] на normalization. В результате dict(combined) становится корректным BBA после объединения двух источников: в нем перечислены только непустые пересечения, а их массы нормализованы.
В реальной задаче источников больше двух, так что поверх combine_masses я построил простую обертку — функцию dst_process:
def dst_process(mass: list[BBAdict,], fallback: frozenset[str]) -> str: combination = combine_masses(mass[0], mass[1], fallback) for m in range(2, len(mass)): combination = combine_masses(combination, mass[m], fallback) # belief sort ranked = sorted(((",".join(sorted(k)), v) for k, v in combination.items() if k), key=lambda x: -x[1]) return ranked[0][0] # best guess
На вход она получает список mass, где каждый элемент — это BBA от отдельного источника, например, от поля VENDOR_PN, от CONNECTOR, от Power Class и так далее. Сначала объединяются первые два распределения: combination = combine_masses(mass[0], mass[1]), затем результат последовательно комбинируется со следующим источником в цикле. Такая итеративная схема использует ассоциативность правила Демпстера: мы можем объединять свидетельства по одному и в итоге получить тот же смысловой результат, что и при «одновременном» объединении всех источников.
Когда все источники сведены в одно распределение combination, остается извлечь практический ответ. Я сортирую гипотезы по убыванию массы и беру первую как best guess. В коде для удобства множество гипотез преобразуется в строку через запятую: ",".join(sorted(k)). Это важный момент: итоговая гипотеза не обязана быть одиночной. Если данных недостаточно, DST может честно вернуть составное множество в��оде DAC,AEC,ACC — то есть «мы уверены, что это медная кабельная сборка, но по имеющимся свидетельствам нельзя корректно различить пассивный DAC и активные AEC/ACC». В этом и состоит практическая ценность подхода: вместо принудительного выбора одного класса любой ценой, система может вернуть обобщенный вывод, сохранив корректность при неполных данных.
Общий пайплайн получился таким:
По интерфейсу (SFP/QSFP/CMIS) выбирается соответствующий
bba_pack.По каждому доступному полю извлекается или вычисляется BBA.
BBA складываются в список.
Список сворачивается через
dst_process, и на выходе получается итоговая гипотеза с максимальной массой.
Напоследок вы можете самостоятельно определить новые BBA, добавить их в словарь sfp_bba_pack и дополнить eeprom_data соответствующими ключами.
Листинг рабочего примера на Python 3.11
from collections import defaultdict from pprint import pprint from typing import Callable, Literal, TypedDict, cast # aliases for common types and literals BBATypeLiteral = Literal["cable_type", "media_type"] BBAdict = dict[frozenset[str], float] BBABaseDict = dict[str | bool, BBAdict] BBAExtendedDict = dict[str | int, dict[BBATypeLiteral, BBAdict]] BBACallable = Callable[[str], BBAdict] BBAContainer = BBAExtendedDict | BBABaseDict | BBACallable BBAPackDict = dict[str, BBAContainer] class ExtSpecTyped(TypedDict, total=False): QSFP: str SFP: str CMIS: str XcvrTypeLiteral = Literal["QSFP", "SFP", "CMIS"] PrimitiveCodecUnion = str | int | float | bool | None ExtSpecValue = str | ExtSpecTyped XcvrCodecUnion = PrimitiveCodecUnion | ExtSpecValue | dict[str, str | bool] XcvrEepromDataDict = dict[str, XcvrCodecUnion] # hypoteses OMEGA_CABLE_TYPES = frozenset({"AOC", "DAC", "AEC", "ACC", "Separable"}) OMEGA_MEDIA_TYPES = frozenset({"copper", "fiber"}) CABLE_TYPES_UNKNOWN_MASS = {OMEGA_CABLE_TYPES: 1.0} MEDIA_TYPES_UNKNOWN_MASS = {OMEGA_MEDIA_TYPES: 1.0} vendor_pn_aoc_bba: BBABaseDict = {True: {frozenset(("AOC",)): 0.7, OMEGA_CABLE_TYPES: 0.3}} vendor_pn_dac_bba: BBABaseDict = {True: {frozenset({"DAC", "AEC", "ACC"}): 0.7, OMEGA_CABLE_TYPES: 0.3}} connector_type_bba: BBAExtendedDict = { "Copper Pigtail": { "cable_type": {frozenset(("DAC", "AEC", "ACC")): 0.95, OMEGA_CABLE_TYPES: 0.05}, "media_type": {frozenset(("copper",)): 0.95, OMEGA_MEDIA_TYPES: 0.05}, }, "No separable connector": { "cable_type": {frozenset(("DAC", "AEC", "ACC")): 0.8, frozenset(("Separable",)): 0.15, OMEGA_CABLE_TYPES: 0.05}, "media_type": {frozenset(("copper",)): 0.8, frozenset(("fiber",)): 0.15, OMEGA_MEDIA_TYPES: 0.05}, }, "Optical Pigtail": { "cable_type": {frozenset(("AOC",)): 0.95, OMEGA_CABLE_TYPES: 0.05}, "media_type": {frozenset(("fiber",)): 0.95, OMEGA_MEDIA_TYPES: 0.05}, }, "RJ45": { "cable_type": {frozenset(("DAC",)): 0.1, frozenset(("Separable",)): 0.85, OMEGA_CABLE_TYPES: 0.05}, "media_type": {frozenset(("copper",)): 0.95, OMEGA_MEDIA_TYPES: 0.05}, }, "MXC 2x16": { "cable_type": {frozenset(("AOC",)): 0.2, frozenset(("Separable",)): 0.75, OMEGA_CABLE_TYPES: 0.05}, "media_type": {frozenset(("fiber",)): 0.95, OMEGA_MEDIA_TYPES: 0.05}, }, "MPO 2x16": { "cable_type": {frozenset(("AOC",)): 0.2, frozenset(("Separable",)): 0.75, OMEGA_CABLE_TYPES: 0.05}, "media_type": {frozenset(("fiber",)): 0.95, OMEGA_MEDIA_TYPES: 0.05}, }, "LC": { "cable_type": {frozenset(("AOC",)): 0.2, frozenset(("Separable",)): 0.75, OMEGA_CABLE_TYPES: 0.05}, "media_type": {frozenset(("fiber",)): 0.95, OMEGA_MEDIA_TYPES: 0.05}, }, "SC": { "cable_type": {frozenset(("AOC",)): 0.2, frozenset(("Separable",)): 0.75, OMEGA_CABLE_TYPES: 0.05}, "media_type": {frozenset(("fiber",)): 0.95, OMEGA_MEDIA_TYPES: 0.05}, }, } power_class_bba: BBABaseDict = { "Power Class 1 (1.5 W max.)": {frozenset(("AOC", "AEC", "ACC")): 0.1, frozenset(("DAC", "Separable")): 0.85, OMEGA_CABLE_TYPES: 0.05}, "Power Class 2 (2.0 W max.)": {frozenset(("AOC", "AEC", "ACC", "Separable")): 0.75, frozenset(("DAC",)): 0.2, OMEGA_CABLE_TYPES: 0.05}, "Power Class 3 (2.5 W max.)": {frozenset(("AOC", "AEC", "ACC", "Separable")): 0.8, frozenset(("DAC",)): 0.15, OMEGA_CABLE_TYPES: 0.05}, "Power Class 4 (3.5 W max.)": {frozenset(("AOC", "AEC", "ACC", "Separable")): 0.85, frozenset(("DAC",)): 0.1, OMEGA_CABLE_TYPES: 0.05}, } def get_vendor_pn_mass(vendor_pn: str) -> BBAdict: return ( vendor_pn_aoc_bba.get("aoc" in vendor_pn) or vendor_pn_dac_bba.get("dac" in vendor_pn) or CABLE_TYPES_UNKNOWN_MASS ) def combine_masses(m1: BBAdict, m2: BBAdict, fallback: frozenset[str] = frozenset({"Separable"})) -> BBAdict: """Combines two belief assignments using Dempster's rule with a fallback in case of total conflict.""" combined: defaultdict[frozenset[str], float] = defaultdict(float) conflict_mass = 0.0 for a_set, mass_a in m1.items(): for b_set, mass_b in m2.items(): intersect = frozenset(a_set & b_set) if intersect: combined[intersect] += mass_a * mass_b else: conflict_mass += mass_a * mass_b if conflict_mass == 1.0: return {fallback: 1.0} # Normalization normalization = 1.0 - conflict_mass for k in combined: combined[k] /= normalization return dict(combined) def dst_process(mass: list[BBAdict,]) -> str: combination = combine_masses(mass[0], mass[1]) for m in range(2, len(mass)): combination = combine_masses(combination, mass[m]) # belief sort ranked = sorted(((",".join(sorted(k)), v) for k, v in combination.items() if k), key=lambda x: -x[1]) print("Final sorted combinations:") pprint(ranked) return ranked[0][0] # best guess def get_bba( key: bool | str | int, bba_container: BBAExtendedDict | BBABaseDict, bba_type: BBATypeLiteral ) -> BBAdict | None: bba: BBAdict | dict[BBATypeLiteral, BBAdict] | None if isinstance(key, bool): bba = cast(BBABaseDict, bba_container).get(key) elif isinstance(key, int): bba = cast(BBAExtendedDict, bba_container).get(key) else: bba = bba_container.get(key) if bba is None: return CABLE_TYPES_UNKNOWN_MASS if bba_type == "cable_type" else MEDIA_TYPES_UNKNOWN_MASS if isinstance(bba, dict) and "cable_type" in bba: ext = cast(dict[BBATypeLiteral, BBAdict], bba) return ext.get(bba_type) return cast(BBAdict, bba) def get_mass( data: XcvrEepromDataDict, bba_pack: BBAPackDict, bba_type: BBATypeLiteral ) -> list[BBAdict,]: masses = [] uknown_mass = CABLE_TYPES_UNKNOWN_MASS if bba_type == "cable_type" else MEDIA_TYPES_UNKNOWN_MASS for eeprom_field, mass in bba_pack.items(): match eeprom_field: case "VENDOR_PN": value = cast(str, data.get(eeprom_field) or "Unknown").lower() masses.append(cast(BBACallable, mass)(value)) case _: raw_value = cast(str | int | bool, data.get(eeprom_field) or "Unknown") masses.append( get_bba(raw_value, cast(BBABaseDict | BBAExtendedDict, mass), bba_type) or uknown_mass ) return masses if __name__ == "__main__": eeprom_data: XcvrEepromDataDict = { "VENDOR_PN": "SNR-SFP28-DAC", "CONNECTOR": "Copper Pigtail", "POWER_LEVEL": "Power Class 1 (1.5 W max.)", } sfp_bba_pack: BBAPackDict = { "VENDOR_PN": get_vendor_pn_mass, "CONNECTOR": connector_type_bba, "POWER_LEVEL": power_class_bba, } masses = get_mass(eeprom_data, sfp_bba_pack, "cable_type") result = dst_process(masses) print(f"\nBest guess is {result}")
Ну что, время итогов. Я показал, как задачу принятия решений на основе неполных и противоречивых данных можно формализовать и выполнить с использованием теории Демпстера-Шафера. Приведенный подход — не универсальный рецепт, а один из инструментов, которые хорошо показывают себя в реальной системе. Буду рад вопросам, комментариям и обсуждению. Особенно интересно услышать о похожих задачах и альтернативных подходах, с которыми вы сталкивались на практике.