├── tests ├── __init__.py ├── pytest.ini ├── examples.py └── test_ahp.py ├── src └── ahpy │ ├── _version.py │ ├── __init__.py │ └── ahpy.py ├── pyproject.toml ├── LICENSE └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/ahpy/_version.py: -------------------------------------------------------------------------------- 1 | __version__ = '2.1' 2 | -------------------------------------------------------------------------------- /src/ahpy/__init__.py: -------------------------------------------------------------------------------- 1 | from ._version import __version__ 2 | from .ahpy import * 3 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | ignore: .*:DeprecationWarning -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "AHPy" 7 | dependencies = [ 8 | "numpy", 9 | "scipy", 10 | ] 11 | dynamic = ["version"] 12 | requires-python = ">=3.7" 13 | authors = [ 14 | {name = "Philip Griffith", email = "philip.griffith@gmail.com"}, 15 | ] 16 | description = "A Python implementation of the Analytic Hierarchy Process" 17 | readme = "README.md" 18 | license = "MIT" 19 | license-files = ["LICENSE"] 20 | keywords = ["AHP", "MCDM", "MCDA"] 21 | classifiers = [ 22 | "Operating System :: OS Independent", 23 | ] 24 | 25 | [project.urls] 26 | Repository = "https://github.com/PhilipGriffith/AHPy" 27 | 28 | [tool.setuptools.packages.find] 29 | where = ["src"] 30 | 31 | [tool.setuptools.dynamic] 32 | version = {attr = "ahpy._version.__version__"} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Philip Griffith 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/examples.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | import pytest 4 | 5 | from src import ahpy 6 | 7 | 8 | # Example from https://en.wikipedia.org/wiki/Analytic_hierarchy_process_%E2%80%93_leader_example 9 | 10 | # experience_comparisons = {('Moll', 'Nell'): 1 / 4, ('Moll', 'Sue'): 4, ('Nell', 'Sue'): 9} 11 | # education_comparisons = {('Moll', 'Nell'): 3, ('Moll', 'Sue'): 1 / 5, ('Nell', 'Sue'): 1 / 7} 12 | # charisma_comparisons = {('Moll', 'Nell'): 5, ('Moll', 'Sue'): 9, ('Nell', 'Sue'): 4} 13 | # age_comparisons = {('Moll', 'Nell'): 1 / 3, ('Moll', 'Sue'): 5, ('Nell', 'Sue'): 9} 14 | # criteria_comparisons = {('Experience', 'Education'): 4, ('Experience', 'Charisma'): 3, ('Experience', 'Age'): 7, 15 | # ('Education', 'Charisma'): 1 / 3, ('Education', 'Age'): 3, 16 | # ('Charisma', 'Age'): 5} 17 | # 18 | # experience = ahpy.Compare('Experience', experience_comparisons, precision=3, random_index='saaty') 19 | # education = ahpy.Compare('Education', education_comparisons, precision=3, random_index='saaty') 20 | # charisma = ahpy.Compare('Charisma', charisma_comparisons, precision=3, random_index='saaty') 21 | # age = ahpy.Compare('Age', age_comparisons, precision=3, random_index='saaty') 22 | # criteria = ahpy.Compare('Criteria', criteria_comparisons, precision=3, random_index='saaty') 23 | # 24 | # criteria.add_children([experience, education, charisma, age]) 25 | 26 | # ---------------------------------------------------------------------------------- 27 | # Example from Saaty, Thomas L., 'Decision making with the analytic hierarchy process,' 28 | # Int. J. Services Sciences, 1:1, 2008, pp. 83-98. 29 | 30 | # drinks_m = {('coffee', 'wine'): 9, ('coffee', 'tea'): 5, ('coffee', 'beer'): 2, ('coffee', 'soda'): 1, 31 | # ('coffee', 'milk'): 1, ('water', 'coffee'): 2, ('tea', 'wine'): 3, ('beer', 'wine'): 9, ('beer', 'tea'): 3, 32 | # ('beer', 'milk'): 1, ('soda', 'wine'): 9, ('soda', 'tea'): 4, ('soda', 'beer'): 2, ('soda', 'milk'): 2, 33 | # ('milk', 'wine'): 9, ('milk', 'tea'): 3, ('water', 'coffee'): 2, ('water', 'wine'): 9, ('water', 'tea'): 9, 34 | # ('water', 'beer'): 3, ('water', 'soda'): 2, ('water', 'milk'): 3} 35 | # drinks = ahpy.Compare('Drinks', drinks_m, precision=3, random_index='saaty') 36 | 37 | # drinks_missing = {('coffee', 'wine'): 9, ('coffee', 'tea'): 5, ('coffee', 'beer'): 2, 38 | # ('coffee', 'milk'): 1, 39 | # ('wine', 'tea'): 1 / 3, ('wine', 'beer'): 1 / 9, 40 | # ('wine', 'milk'): 1 / 9, 41 | # ('tea', 'beer'): 1 / 3, ('tea', 'soda'): 1 / 4, 42 | # ('tea', 'water'): 1 / 9, 43 | # ('beer', 'soda'): 1 / 2, ('beer', 'milk'): 1, 44 | # ('soda', 'milk'): 2, 45 | # ('milk', 'water'): 1 / 3 46 | # } 47 | # drinks_missing = ahpy.Compare('Drinks', drinks_missing, precision=3, random_index='saaty') 48 | 49 | # ---------------------------------------------------------------------------------- 50 | # Example from https://mi.boku.ac.at/ahp/ahptutorial.pdf 51 | 52 | # cars = ('civic', 'saturn', 'escort', 'clio') 53 | # 54 | # gas_m = dict(zip(cars, (34, 27, 24, 28))) 55 | # gas = ahpy.Compare('gas', gas_m, precision=3) 56 | # 57 | # rel_m = dict(zip(itertools.combinations(cars, 2), (2, 5, 1, 3, 2, 0.25))) 58 | # rel = ahpy.Compare('rel', rel_m, 3) 59 | # 60 | # style_m = {('civic', 'escort'): 4, 61 | # ('saturn', 'civic'): 4, ('saturn', 'escort'): 4, ('saturn', 'clio'): 0.25, 62 | # ('clio', 'civic'): 6, ('clio', 'escort'): 5} 63 | # style = ahpy.Compare('style', style_m, precision=3) 64 | # 65 | # cri_m = {('style', 'rel'): 0.5, ('style', 'gas'): 3, 66 | # ('rel', 'gas'): 4} 67 | # goal = ahpy.Compare('goal', cri_m) 68 | # 69 | # goal.add_children([gas, rel, style]) 70 | 71 | # ---------------------------------------------------------------------------------- 72 | # Example from Saaty, Thomas, L., Theory and Applications of the Analytic Network Process, 2005. 73 | # Also at https://www.passagetechnology.com/what-is-the-analytic-hierarchy-process 74 | 75 | # criteria = {('Culture', 'Housing'): 3, ('Culture', 'Transportation'): 5, 76 | # ('Family', 'Culture'): 5, ('Family', 'Housing'): 7, ('Family', 'Transportation'): 7, 77 | # ('Housing', 'Transportation'): 3, 78 | # ('Jobs', 'Culture'): 2, ('Jobs', 'Housing'): 4, ('Jobs', 'Transportation'): 7, 79 | # ('Family', 'Jobs'): 1} 80 | # 81 | # culture = {('Bethesda', 'Pittsburgh'): 1, 82 | # ('Boston', 'Bethesda'): 2, ('Boston', 'Pittsburgh'): 2.5, ('Boston', 'Santa Fe'): 1, 83 | # ('Pittsburgh', 'Bethesda'): 1, 84 | # ('Santa Fe', 'Bethesda'): 2, ('Santa Fe', 'Pittsburgh'): 2.5} 85 | # 86 | # family = {('Bethesda', 'Boston'): 2, ('Bethesda', 'Santa Fe'): 4, 87 | # ('Boston', 'Santa Fe'): 2, 88 | # ('Pittsburgh', 'Bethesda'): 3, ('Pittsburgh', 'Boston'): 8, ('Pittsburgh', 'Santa Fe'): 9} 89 | # 90 | # housing = {('Bethesda', 'Boston'): 5, ('Bethesda', 'Santa Fe'): 2.5, 91 | # ('Pittsburgh', 'Bethesda'): 2, ('Pittsburgh', 'Boston'): 9, ('Pittsburgh', 'Santa Fe'): 7, 92 | # ('Santa Fe', 'Boston'): 4} 93 | # 94 | # jobs = {('Bethesda', 'Pittsburgh'): 3, ('Bethesda', 'Santa Fe'): 4, 95 | # ('Boston', 'Bethesda'): 2, ('Boston', 'Pittsburgh'): 6, ('Boston', 'Santa Fe'): 8, 96 | # ('Pittsburgh', 'Santa Fe'): 1} 97 | # 98 | # transportation = {('Bethesda', 'Boston'): 1.5, 99 | # ('Bethesda', 'Santa Fe'): 4, 100 | # ('Boston', 'Santa Fe'): 2.5, 101 | # ('Pittsburgh', 'Bethesda'): 2, 102 | # ('Pittsburgh', 'Boston'): 3.5, 103 | # ('Pittsburgh', 'Santa Fe'): 9} 104 | # 105 | # cu = ahpy.Compare('Culture', culture, precision=3, random_index='Saaty') 106 | # f = ahpy.Compare('Family', family, precision=3, random_index='Saaty') 107 | # h = ahpy.Compare('Housing', housing, precision=3, random_index='Saaty') 108 | # j = ahpy.Compare('Jobs', jobs, precision=3, random_index='Saaty') 109 | # t = ahpy.Compare('Transportation', transportation, precision=3, random_index='Saaty') 110 | # cr = ahpy.Compare('Goal', criteria, precision=3, random_index='Saaty') 111 | # cr.add_children([cu, f, h, j, t]) 112 | 113 | # ---------------------------------------------------------------------------------- 114 | # Example from https://en.wikipedia.org/wiki/Analytic_hierarchy_process_%E2%80%93_car_example 115 | 116 | 117 | def m(elements, judgments): 118 | return dict(zip(elements, judgments)) 119 | 120 | 121 | cri = ('Cost', 'Safety', 'Style', 'Capacity') 122 | c_cri = list(itertools.combinations(cri, 2)) 123 | criteria = ahpy.Compare('Criteria', m(c_cri, (3, 7, 3, 9, 1, 1 / 7)), 3) 124 | 125 | alt = ('Accord Sedan', 'Accord Hybrid', 'Pilot', 'CR-V', 'Element', 'Odyssey') 126 | pairs = list(itertools.combinations(alt, 2)) 127 | 128 | costs = ('Price', 'Fuel', 'Maintenance', 'Resale') 129 | c_pairs = list(itertools.combinations(costs, 2)) 130 | cost = ahpy.Compare('Cost', m(c_pairs, (2, 5, 3, 2, 2, .5)), precision=3) 131 | 132 | cost_price_m = (9, 9, 1, 0.5, 5, 1, 1 / 9, 1 / 9, 1 / 7, 1 / 9, 1 / 9, 1 / 7, 1 / 2, 5, 6) 133 | cost_price = ahpy.Compare('Price', m(pairs, cost_price_m), 3) 134 | 135 | # cost_fuel_m = (1/1.13, 1.41, 1.15, 1.24, 1.19, 1.59, 1.3, 1.4, 1.35, 1/1.23, 1/1.14, 1/1.18, 1.08, 1.04, 1/1.04) 136 | cost_fuel_m = (31, 35, 22, 27, 25, 26) 137 | # cost_fuel = ahpy.Compare('Fuel', m(pairs, cost_fuel_m), 3) 138 | cost_fuel = ahpy.Compare('Fuel', m(alt, cost_fuel_m), 3) 139 | print(cost_fuel.comparisons) 140 | 141 | # cost_resale_m = (3, 4, 1 / 2, 2, 2, 2, 1 / 5, 1, 1, 1 / 6, 1 / 2, 1 / 2, 4, 4, 1) 142 | cost_resale_m = (0.52, 0.46, 0.44, 0.55, 0.48, 0.48) 143 | # cost_resale = ahpy.Compare('Resale', m(pairs, cost_resale_m), 3) 144 | cost_resale = ahpy.Compare('Resale', m(alt, cost_resale_m), 3) 145 | 146 | cost_maint_m = (1.5, 4, 4, 4, 5, 4, 4, 4, 5, 1, 1.2, 1, 1, 3, 2) 147 | cost_maint = ahpy.Compare('Maintenance', m(pairs, cost_maint_m), 3) 148 | 149 | safety_m = (1, 5, 7, 9, 1 / 3, 5, 7, 9, 1 / 3, 2, 9, 1 / 8, 2, 1 / 8, 1 / 9) 150 | safety = ahpy.Compare('Safety', m(pairs, safety_m), 3) 151 | 152 | style_m = (1, 7, 5, 9, 6, 7, 5, 9, 6, 1 / 6, 3, 1 / 3, 7, 5, 1 / 5) 153 | style = ahpy.Compare('Style', m(pairs, style_m), 3) 154 | 155 | capacity = ahpy.Compare('Capacity', {('Cargo', 'Passenger'): 0.2}) 156 | 157 | # capacity_pass_m = (1, 1 / 2, 1, 3, 1 / 2, 1 / 2, 1, 3, 1 / 2, 2, 6, 1, 3, 1 / 2, 1 / 6) 158 | capacity_pass_m = (5, 5, 8, 5, 4, 8) 159 | # capacity_pass = ahpy.Compare('Passenger', m(pairs, capacity_pass_m), 3) 160 | capacity_pass = ahpy.Compare('Passenger', m(alt, capacity_pass_m), 3) 161 | print(m(pairs, capacity_pass_m)) 162 | 163 | # capacity_cargo_m = (1, 1 / 2, 1 / 2, 1 / 2, 1 / 3, 1 / 2, 1 / 2, 1 / 2, 1 / 3, 1, 1, 1 / 2, 1, 1 / 2, 1 / 2) 164 | capacity_cargo_m = (14, 14, 87.6, 72.9, 74.6, 147.4) 165 | # capacity_cargo = ahpy.Compare('Cargo', m(pairs, capacity_cargo_m), precision=3) 166 | capacity_cargo = ahpy.Compare('Cargo', m(alt, capacity_cargo_m), precision=3) 167 | 168 | cost.add_children([cost_price, cost_fuel, cost_maint, cost_resale]) 169 | capacity.add_children([capacity_cargo, capacity_pass]) 170 | criteria.add_children([cost, safety, style, capacity]) 171 | # cost_fuel.report(show=True, complete=True, verbose=True) 172 | # 173 | # compose = ahpy.Compose() 174 | # compose.add_comparisons('Criteria', m(c_cri, (3, 7, 3, 9, 1, 1 / 7)), 3) 175 | # compose.add_comparisons([cost, capacity]) 176 | # compose.add_comparisons('Passenger', m(pairs, capacity_pass_m), 3) 177 | print(m(pairs, capacity_pass_m)) 178 | # compose.add_comparisons([capacity_cargo, cost_price, cost_fuel, cost_resale, cost_maint]) 179 | # # a.add_comparisons([('Price', m(pairs, cost_price_m), 3), ('Fuel', m(pairs, cost_fuel_m), 3)]) 180 | # # a.add_comparisons([['Resale', m(pairs, cost_resale_m), 3], ['Maintenance', m(pairs, cost_maint_m), 3, 'saaty']]) 181 | # compose.add_comparisons((safety, style)) 182 | # h = {'Criteria': ['Cost', 'Safety', 'Style', 'Capacity'], 183 | # 'Cost': ['Price', 'Fuel', 'Resale', 'Maintenance'], 184 | # 'Capacity': ['Passenger', 'Cargo']} 185 | # compose.add_hierarchy(h) 186 | 187 | # ---------------------------------------------------------------------------------- 188 | # Examples from Bozóki, S., Fülöp, J. and Rónyai, L., 'On optimal completion of incomplete pairwise comparison matrices,' 189 | # Mathematical and Computer Modelling, 52:1–2, 2010, pp. 318-333. (https://doi.org/10.1016/j.mcm.2010.02.047) 190 | 191 | # u = {('alpha', 'beta'): 1, ('alpha', 'chi'): 5, ('alpha', 'delta'): 2, 192 | # ('beta', 'chi'): 3, ('beta', 'delta'): 4} # , ('chi', 'delta'): 3/4} 193 | # cu = ahpy.Compare('Incomplete Test', u) 194 | # 195 | # m = {('a', 'b'): 5, ('a', 'c'): 3, ('a', 'd'): 7, ('a', 'e'): 6, ('a', 'f'): 6, 196 | # ('b', 'd'): 5, ('b', 'f'): 3, 197 | # ('c', 'e'): 3, ('c', 'g'): 6, 198 | # ('f', 'd'): 4, 199 | # ('g', 'a'): 3, ('g', 'e'): 5, 200 | # ('h', 'a'): 4, ('h', 'b'): 7, ('h', 'd'): 8, ('h', 'f'): 6} 201 | # 202 | # cm = ahpy.Compare('Incomplete Housing', m) 203 | 204 | # ---------------------------------------------------------------------------------- 205 | # Example from Haas, R. and Meixner, L., 'An Illustrated Guide to the Analytic Hierarchy Process,' 206 | # http://www.inbest.co.il/NGO/ahptutorial.pdf 207 | 208 | # f = {'civic': 34, 'saturn': 27, 'escort': 24, 'clio': 28} 209 | # cf = ahpy.Compare('Fuel Economy', f) 210 | # cf.report(show=True) 211 | 212 | # ---------------------------------------------------------------------------------- 213 | # Master Test 214 | 215 | # a_m = {('b', 'c'): 1} 216 | # b_m = {('d', 'e'): 4} 217 | # c_m = {('f', 'g'): 1, ('g', 'h'): 1/2} 218 | # d_m = {('i', 'j'): 2} 219 | # e_m = {'x': 1, 'y': 2, 'z': 3} 220 | # f_m = {('k', 'l'): 1/9} 221 | # g_m = {'x': 1, 'y': 3, 'z': 6} 222 | # h_m = {'x': 2, 'y': 4, 'z': 4} 223 | # i_m = {'x': 2, 'y': 4, 'z': 4} 224 | # j_m = {'x': 1, 'y': 2, 'z': 3} 225 | # k_m = {'x': 2, 'y': 4, 'z': 4} 226 | # l_m = {('m', 'n'): 1} 227 | # m_m = {'x': 1, 'y': 2, 'z': 3} 228 | # n_m = {'x': 1, 'y': 3, 'z': 6} 229 | # 230 | # a = ahpy.Compare('a', a_m, precision=4) 231 | # b = ahpy.Compare('b', b_m, precision=4) 232 | # c = ahpy.Compare('c', c_m, precision=4) 233 | # d = ahpy.Compare('d', d_m, precision=4) 234 | # e = ahpy.Compare('e', e_m, precision=4) 235 | # f = ahpy.Compare('f', f_m, precision=4) 236 | # g = ahpy.Compare('g', g_m, precision=4) 237 | # h = ahpy.Compare('h', h_m, precision=4) 238 | # i = ahpy.Compare('i', i_m, precision=4) 239 | # j = ahpy.Compare('j', j_m, precision=4) 240 | # k = ahpy.Compare('k', k_m, precision=4) 241 | # l = ahpy.Compare('l', l_m, precision=4) 242 | # m = ahpy.Compare('m', m_m, precision=4) 243 | # n = ahpy.Compare('n', n_m, precision=4) 244 | # 245 | # # l.add_children([m, n]) 246 | # # d.add_children([i, j]) 247 | # # f.add_children([k, l]) 248 | # # b.add_children([d, e]) 249 | # # c.add_children([f, g, h]) 250 | # # a.add_children([b, c]) 251 | # 252 | # nodes = [(a, [b, c]), (b, [d, e]), (c, [f, g, h]), (d, [i, j]), (f, [k, l]), (l, [m, n])] 253 | # permutations = itertools.permutations(nodes) 254 | # for permutation in permutations: 255 | # for node in permutation: 256 | # node[0].add_children(node[1]) 257 | # 258 | # h.report() 259 | # 260 | # assert a.report(verbose=True) == {'name': 'a', 'global_weight': 1.0, 'local_weight': 1.0, 'target_weights': {'z': 0.4652, 'y': 0.3626, 'x': 0.1723}, 'elements': {'global_weights': {'b': 0.5, 'c': 0.5}, 'local_weights': {'b': 0.5, 'c': 0.5}, 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 'count': 2, 'names': ['b', 'c']}, 'children': {'count': 2, 'names': ['b', 'c']}, 'comparisons': {'count': 1, 'input': {('b', 'c'): 1}, 'computed': None}} 261 | # assert b.report(verbose=True) == {'name': 'b', 'global_weight': 0.5, 'local_weight': 0.5, 'target_weights': None, 'elements': {'global_weights': {'d': 0.4, 'e': 0.1}, 'local_weights': {'d': 0.8, 'e': 0.2}, 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 'count': 2, 'names': ['d', 'e']}, 'children': {'count': 2, 'names': ['d', 'e']}, 'comparisons': {'count': 1, 'input': {('d', 'e'): 4}, 'computed': None}} 262 | # assert c.report(verbose=True) == {'name': 'c', 'global_weight': 0.5, 'local_weight': 0.5, 'target_weights': None, 'elements': {'global_weights': {'h': 0.25, 'f': 0.125, 'g': 0.125}, 'local_weights': {'h': 0.5, 'f': 0.25, 'g': 0.25}, 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 'count': 3, 'names': ['f', 'g', 'h']}, 'children': {'count': 3, 'names': ['f', 'g', 'h']}, 'comparisons': {'count': 3, 'input': {('f', 'g'): 1, ('g', 'h'): 0.5}, 'computed': pytest.approx({('f', 'h'): 0.5000007807004769})}} 263 | # assert d.report(verbose=True) == {'name': 'd', 'global_weight': 0.4, 'local_weight': 0.8, 'target_weights': None, 'elements': {'global_weights': {'i': 0.2667, 'j': 0.1333}, 'local_weights': {'i': 0.6667, 'j': 0.3333}, 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 'count': 2, 'names': ['i', 'j']}, 'children': {'count': 2, 'names': ['i', 'j']}, 'comparisons': {'count': 1, 'input': {('i', 'j'): 2}, 'computed': None}} 264 | # assert e.report(verbose=True) == {'name': 'e', 'global_weight': 0.1, 'local_weight': 0.2, 'target_weights': None, 'elements': {'global_weights': {'z': 0.05, 'y': 0.0333, 'x': 0.0167}, 'local_weights': {'z': 0.5, 'y': 0.3333, 'x': 0.1667}, 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 'count': 3, 'names': ['x', 'y', 'z']}, 'children': None, 'comparisons': {'count': 3, 'input': {'x': 1, 'y': 2, 'z': 3}, 'computed': None}} 265 | # assert f.report(verbose=True) == {'name': 'f', 'global_weight': 0.125, 'local_weight': 0.25, 'target_weights': None, 'elements': {'global_weights': {'l': 0.1125, 'k': 0.0125}, 'local_weights': {'l': 0.9, 'k': 0.1}, 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 'count': 2, 'names': ['k', 'l']}, 'children': {'count': 2, 'names': ['k', 'l']}, 'comparisons': {'count': 1, 'input': {('k', 'l'): 0.1111111111111111}, 'computed': None}} 266 | # assert g.report(verbose=True) == {'name': 'g', 'global_weight': 0.125, 'local_weight': 0.25, 'target_weights': None, 'elements': {'global_weights': {'z': 0.075, 'y': 0.0375, 'x': 0.0125}, 'local_weights': {'z': 0.6, 'y': 0.3, 'x': 0.1}, 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 'count': 3, 'names': ['x', 'y', 'z']}, 'children': None, 'comparisons': {'count': 3, 'input': {'x': 1, 'y': 3, 'z': 6}, 'computed': None}} 267 | # assert h.report(verbose=True) == {'name': 'h', 'global_weight': 0.25, 'local_weight': 0.5, 'target_weights': None, 'elements': {'global_weights': {'y': 0.1, 'z': 0.1, 'x': 0.05}, 'local_weights': {'y': 0.4, 'z': 0.4, 'x': 0.2}, 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 'count': 3, 'names': ['x', 'y', 'z']}, 'children': None, 'comparisons': {'count': 3, 'input': {'x': 2, 'y': 4, 'z': 4}, 'computed': None}} 268 | # assert i.report(verbose=True) == {'name': 'i', 'global_weight': 0.2667, 'local_weight': 0.6667, 'target_weights': None, 'elements': {'global_weights': {'y': 0.1067, 'z': 0.1067, 'x': 0.0533}, 'local_weights': {'y': 0.4, 'z': 0.4, 'x': 0.2}, 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 'count': 3, 'names': ['x', 'y', 'z']}, 'children': None, 'comparisons': {'count': 3, 'input': {'x': 2, 'y': 4, 'z': 4}, 'computed': None}} 269 | # assert j.report(verbose=True) == {'name': 'j', 'global_weight': 0.1333, 'local_weight': 0.3333, 'target_weights': None, 'elements': {'global_weights': {'z': 0.0666, 'y': 0.0444, 'x': 0.0222}, 'local_weights': {'z': 0.5, 'y': 0.3333, 'x': 0.1667}, 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 'count': 3, 'names': ['x', 'y', 'z']}, 'children': None, 'comparisons': {'count': 3, 'input': {'x': 1, 'y': 2, 'z': 3}, 'computed': None}} 270 | # assert k.report(verbose=True) == {'name': 'k', 'global_weight': 0.0125, 'local_weight': 0.1, 'target_weights': None, 'elements': {'global_weights': {'y': 0.005, 'z': 0.005, 'x': 0.0025}, 'local_weights': {'y': 0.4, 'z': 0.4, 'x': 0.2}, 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 'count': 3, 'names': ['x', 'y', 'z']}, 'children': None, 'comparisons': {'count': 3, 'input': {'x': 2, 'y': 4, 'z': 4}, 'computed': None}} 271 | # assert l.report(verbose=True) == {'name': 'l', 'global_weight': 0.1125, 'local_weight': 0.9, 'target_weights': None, 'elements': {'global_weights': {'m': 0.0562, 'n': 0.0562}, 'local_weights': {'m': 0.5, 'n': 0.5}, 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 'count': 2, 'names': ['m', 'n']}, 'children': {'count': 2, 'names': ['m', 'n']}, 'comparisons': {'count': 1, 'input': {('m', 'n'): 1}, 'computed': None}} 272 | # assert m.report(verbose=True) == {'name': 'm', 'global_weight': 0.0562, 'local_weight': 0.5, 'target_weights': None, 'elements': {'global_weights': {'z': 0.0281, 'y': 0.0187, 'x': 0.0094}, 'local_weights': {'z': 0.5, 'y': 0.3333, 'x': 0.1667}, 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 'count': 3, 'names': ['x', 'y', 'z']}, 'children': None, 'comparisons': {'count': 3, 'input': {'x': 1, 'y': 2, 'z': 3}, 'computed': None}} 273 | # assert n.report(verbose=True) == {'name': 'n', 'global_weight': 0.0562, 'local_weight': 0.5, 'target_weights': None, 'elements': {'global_weights': {'z': 0.0337, 'y': 0.0169, 'x': 0.0056}, 'local_weights': {'z': 0.6, 'y': 0.3, 'x': 0.1}, 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 'count': 3, 'names': ['x', 'y', 'z']}, 'children': None, 'comparisons': {'count': 3, 'input': {'x': 1, 'y': 3, 'z': 6}, 'computed': None}} 274 | -------------------------------------------------------------------------------- /src/ahpy/ahpy.py: -------------------------------------------------------------------------------- 1 | import bisect 2 | import copy 3 | import itertools 4 | import json 5 | import warnings 6 | 7 | import numpy as np 8 | import scipy.optimize as spo 9 | 10 | 11 | class Compare: 12 | """ 13 | This class computes the priority vector and consistency ratio of a positive reciprocal matrix, created using 14 | an input dictionary of pairwise comparison values. Optimal values are computed for any missing pairwise comparisons. 15 | NB: The 'name' property is used to link a child Compare object to its parent. 16 | :param name: string, the name of the Compare object; 17 | if the object has a parent, this name MUST be included as an element of its parent 18 | :param comparisons: dictionary, a dictionary in one of two forms: (i) each key is a tuple of two elements and 19 | each value is their pairwise comparison value, or (ii) each key is a single element and each value 20 | is that element's measured value 21 | Examples: (i) {('a', 'b'): 3, ('b', 'c'): 2}, (ii) {'a': 1.2, 'b': 2.3, 'c': 3.4} 22 | :param precision: integer, number of decimal places used when computing both the priority 23 | vector and the consistency ratio; default is 4 24 | :param random_index: string, the random index estimates used to compute the consistency ratio; 25 | see '_compute_consistency_ratio()' for more information regarding the different estimates; 26 | valid input: 'dd', 'saaty'; default is 'dd' 27 | :param iterations: integer, number of iterations before '_compute_priority_vector()' stops; 28 | default is 100 29 | :param tolerance: float, the stopping criteria for the cycling coordinates algorithm instantiated by 30 | '_complete_matrix()'; the algorithm stops when the difference between the norms of two cycles 31 | of coordinates is less than this value; default is 0.0001 32 | :param cr: boolean, whether to compute the priority vector's consistency ratio; default is True 33 | """ 34 | 35 | def __init__(self, name, comparisons, precision=4, random_index='dd', iterations=100, tolerance=0.0001, cr=True): 36 | self.name = name 37 | self.comparisons = comparisons 38 | self.precision = precision 39 | self.random_index = random_index.lower() if cr else None 40 | self.iterations = iterations 41 | self.tolerance = tolerance 42 | self.cr = cr 43 | 44 | self._normalize = not isinstance(next(iter(self.comparisons)), tuple) 45 | self._elements = [] 46 | self._pairs = [] 47 | self._size = None 48 | self._matrix = None 49 | self._missing_comparisons = None 50 | 51 | self._node_parent = None 52 | self._node_children = None 53 | self._node_precision = self.precision 54 | self._node_weights = None 55 | 56 | self.global_weight = 1.0 57 | self.local_weight = self.global_weight 58 | self.consistency_ratio = None 59 | self.global_weights = None 60 | self.local_weights = None 61 | self.target_weights = None 62 | 63 | self._check_input() 64 | if self._normalize: 65 | self._build_normalized_elements() 66 | self._check_size() 67 | self._build_normalized_matrix() 68 | else: 69 | self._build_elements() 70 | self._check_size() 71 | self._insert_comparisons() 72 | self._build_matrix() 73 | self._get_missing_comparisons() 74 | if self._missing_comparisons: 75 | self._complete_matrix() 76 | self._compute() 77 | 78 | self.target_weights = self._node_weights if self.global_weight == 1.0 else None 79 | 80 | def __getitem__(self, item): 81 | return getattr(self, item) 82 | 83 | def _check_input(self): 84 | """ 85 | Raises a ValueError if an input value is not greater than zero; 86 | raises a TypeError if an input value cannot be cast to a float. 87 | """ 88 | for key, value in self.comparisons.items(): 89 | try: 90 | if not float(value) > 0: 91 | msg = f'{key}: {value} is an invalid input. All input values must be greater than zero.' 92 | raise ValueError(msg) 93 | except TypeError: 94 | msg = f'{key}: {value} is an invalid input. All input values must be numeric.' 95 | raise TypeError(msg) 96 | 97 | def _build_elements(self): 98 | """ 99 | Creates an empty 'pairs' dictionary that contains all possible permutations 100 | of those elements found within the keys of the input 'comparisons' dictionary. 101 | """ 102 | for key in self.comparisons: 103 | for element in key: 104 | if element not in self._elements: 105 | self._elements.append(element) 106 | self._pairs = dict.fromkeys(itertools.permutations(self._elements, 2)) 107 | self._size = len(self._elements) 108 | 109 | def _build_normalized_elements(self): 110 | """ 111 | Creates a list of those elements found within the keys of the input 'comparisons' dictionary. 112 | """ 113 | self._elements = list(self.comparisons) 114 | self._pairs = {} 115 | self._size = len(self._elements) 116 | 117 | def _check_size(self): 118 | """ 119 | Raises a ValueError if a consistency ratio is requested and 120 | the chosen random index does not support the size of the matrix. 121 | """ 122 | if not self._normalize and self.cr and \ 123 | ((self.random_index == 'saaty' and self._size > 15) or self._size > 100): 124 | msg = f"The input matrix of {self._size} x {self._size} is too large for {self.random_index}" \ 125 | " and a consistency ratio cannot be computed.\n" \ 126 | "\tThe maximum matrix size supported by the 'saaty' random index is 15 x 15;\n" \ 127 | "\tthe maximum matrix size supported by the 'dd' random index is 100 x 100.\n" \ 128 | "\tTo compute the priority vector of the matrix without a consistency ratio\n," \ 129 | "\tuse the 'cr=False' argument." 130 | raise ValueError(msg) 131 | 132 | def _insert_comparisons(self): 133 | """ 134 | Fills the entries of the 'pairs' dictionary with the corresponding comparison values 135 | of the input 'comparisons' dictionary or their computed reciprocals. 136 | """ 137 | for key, value in self.comparisons.items(): 138 | inverse_key = key[::-1] 139 | self._pairs[key] = float(value) 140 | self._pairs[inverse_key] = np.reciprocal(float(value)) 141 | 142 | def _build_matrix(self): 143 | """ 144 | Creates a correctly-sized numpy matrix of 1s, then fills the matrix with values from the 'pairs' dictionary. 145 | """ 146 | self._matrix = np.ones((self._size, self._size)) 147 | for pair, value in self._pairs.items(): 148 | location = tuple(self._elements.index(elements) for elements in pair) 149 | self._matrix[location] = value 150 | 151 | def _build_normalized_matrix(self): 152 | """ 153 | Creates a numpy matrix of values from the input 'comparisons' dictionary. 154 | """ 155 | self._matrix = np.array(tuple(value for value in self.comparisons.values()), float) 156 | 157 | def _get_missing_comparisons(self): 158 | """ 159 | Creates the 'missing comparisons' dictionary by populating its keys with the unique comparisons 160 | missing from the input 'comparisons' dictionary and populating its values with 1s. 161 | """ 162 | missing_comparisons = [key for key, value in self._pairs.items() if not value] 163 | for elements in missing_comparisons: 164 | del missing_comparisons[missing_comparisons.index(elements[::-1])] 165 | self._missing_comparisons = dict.fromkeys(missing_comparisons, 1) 166 | 167 | def _complete_matrix(self): 168 | """ 169 | Optimally completes an incomplete pairwise comparison matrix according to the algorithm described in 170 | Bozóki, S., Fülöp, J. and Rónyai, L., 'On optimal completion of incomplete pairwise comparison matrices,' 171 | Mathematical and Computer Modelling, 52:1–2, 2010, pp. 318-333. (https://doi.org/10.1016/j.mcm.2010.02.047) 172 | """ 173 | last_iteration = np.array(tuple(self._missing_comparisons.values())) 174 | difference = np.inf 175 | while difference > self.tolerance: 176 | self._minimize_coordinate_values() 177 | current_iteration = np.array(tuple(self._missing_comparisons.values())) 178 | difference = np.linalg.norm(last_iteration - current_iteration) 179 | last_iteration = current_iteration 180 | 181 | def _minimize_coordinate_values(self): 182 | """ 183 | Computes the minimum value for each missing value of the 'missing_comparisons' dictionary 184 | using the cyclic coordinates method described in Bozóki et al. 185 | """ 186 | 187 | def lambda_max(x, x_location): 188 | """ 189 | The function to be minimized. Finds the largest eigenvalue of a matrix. 190 | :param x: float, the variable to be minimized 191 | :param x_location: tuple, the matrix location of the variable to be minimized 192 | """ 193 | inverse_x_location = x_location[::-1] 194 | self._matrix[x_location] = x 195 | self._matrix[inverse_x_location] = np.reciprocal(x) 196 | return np.max(np.linalg.eigvals(self._matrix)) 197 | 198 | # The upper bound of the solution space is set to be 10 times the largest value of the matrix. 199 | upper_bound = np.nanmax(self._matrix) * 10 200 | 201 | for comparison in self._missing_comparisons: 202 | comparison_location = tuple(self._elements.index(element) for element in comparison) 203 | with warnings.catch_warnings(): 204 | warnings.filterwarnings('ignore', category=np.exceptions.ComplexWarning) 205 | self._set_matrix(comparison) 206 | optimal_solution = spo.minimize_scalar(lambda_max, args=(comparison_location,), 207 | method='bounded', bounds=(0, upper_bound)) 208 | self._missing_comparisons[comparison] = np.real(optimal_solution.x) 209 | 210 | def _set_matrix(self, comparison): 211 | """ 212 | Sets the value of every missing comparison in the comparison matrix (other than the current comparison) 213 | to its current value in the 'missing_comparisons' dictionary or its reciprocal. 214 | :param comparison: tuple, a key from the 'missing_comparisons' dictionary 215 | """ 216 | for key, value in self._missing_comparisons.items(): 217 | if key != comparison: 218 | location = tuple(self._elements.index(element) for element in key) 219 | inverse_location = location[::-1] 220 | self._matrix[location] = value 221 | self._matrix[inverse_location] = np.reciprocal(value) 222 | 223 | def _compute(self): 224 | """ 225 | Runs all functions necessary for building the local weights and consistency ratio of the Compare object. 226 | """ 227 | if not self._normalize: 228 | priority_vector = self._compute_priority_vector(self._matrix, self.iterations) 229 | if self.cr: 230 | self._compute_consistency_ratio() 231 | else: 232 | priority_vector = np.divide(self._matrix, np.sum(self._matrix, keepdims=True)).round(self.precision) 233 | self.consistency_ratio = 0.0 234 | weights = dict(zip(self._elements, priority_vector)) 235 | self.local_weights = dict(sorted(weights.items(), key=lambda item: item[1], reverse=True)) 236 | self.global_weights = self.local_weights.copy() 237 | self._node_weights = self.local_weights.copy() 238 | self.target_weights = self._node_weights 239 | 240 | def _compute_priority_vector(self, matrix, iterations, comp_eigenvector=None): 241 | """ 242 | Returns the priority vector of the Compare object. 243 | :param matrix: numpy matrix, the matrix from which to derive the priority vector 244 | :param iterations: integer, number of iterations to run before the function stops 245 | :param comp_eigenvector: numpy array, a comparison eigenvector used during recursion 246 | """ 247 | # Compute the principal eigenvector by normalizing the rows of a newly squared matrix 248 | sq_matrix = np.linalg.matrix_power(matrix, 2) 249 | row_sum = np.sum(sq_matrix, axis=1) 250 | total_sum = np.sum(sq_matrix) 251 | principal_eigenvector = np.divide(row_sum, total_sum) 252 | 253 | # Create a zero matrix as the comparison eigenvector if this is the first iteration 254 | if comp_eigenvector is None: 255 | comp_eigenvector = np.zeros(self._size) 256 | 257 | # Compute the difference between the principal and comparison eigenvectors 258 | remainder = np.subtract(principal_eigenvector, comp_eigenvector).round(self.precision) 259 | 260 | # If the difference between the two eigenvectors is zero (after rounding to the specified precision), 261 | # set the current principal eigenvector as the priority vector for the matrix... 262 | if not np.any(remainder): 263 | return principal_eigenvector.round(self.precision) 264 | 265 | # ...else recursively run the function until either there is no difference between the rounded 266 | # principal and comparison eigenvectors, or until the predefined number of iterations has been met, 267 | # in which case set the last principal eigenvector as the priority vector 268 | iterations -= 1 269 | if iterations > 0: 270 | return self._compute_priority_vector(sq_matrix, iterations, principal_eigenvector) 271 | else: 272 | return principal_eigenvector.round(self.precision) 273 | 274 | def _compute_consistency_ratio(self): 275 | """ 276 | Sets the 'consistency_ratio' property of the Compare object, using random index estimates from 277 | Donegan, H.A. and Dodd, F.J., 'A Note on Saaty's Random Indexes,' Mathematical and Computer Modelling, 278 | 15:10, 1991, pp. 135-137 (DOI: 10.1016/0895-7177(91)90098-R) by default (random_index='dd'). 279 | If the random index of the object is 'saaty', uses the estimates from 280 | Saaty's Theory And Applications Of The Analytic Network Process, Pittsburgh: RWS Publications, 2005, p. 31. 281 | """ 282 | # A valid, square, reciprocal matrix with only one or two rows must be consistent 283 | if self._size < 3: 284 | self.consistency_ratio = 0.0 285 | return 286 | if self.random_index == 'saaty': 287 | ri_dict = {3: 0.52, 4: 0.89, 5: 1.11, 6: 1.25, 7: 1.35, 8: 1.40, 9: 1.45, 288 | 10: 1.49, 11: 1.52, 12: 1.54, 13: 1.56, 14: 1.58, 15: 1.59} 289 | elif self.random_index == 'dd': 290 | ri_dict = {3: 0.4914, 4: 0.8286, 5: 1.0591, 6: 1.1797, 7: 1.2519, 291 | 8: 1.3171, 9: 1.3733, 10: 1.4055, 11: 1.4213, 12: 1.4497, 292 | 13: 1.4643, 14: 1.4822, 15: 1.4969, 16: 1.5078, 17: 1.5153, 293 | 18: 1.5262, 19: 1.5313, 20: 1.5371, 25: 1.5619, 30: 1.5772, 294 | 40: 1.5976, 50: 1.6102, 60: 1.6178, 70: 1.6237, 80: 1.6277, 295 | 90: 1.6213, 100: 1.6339} 296 | else: 297 | return 298 | 299 | try: 300 | random_index = ri_dict[self._size] 301 | # If the size of the comparison matrix falls between two computed estimates, compute a weighted estimate 302 | except KeyError: 303 | s = tuple(ri_dict.keys()) 304 | smaller = s[bisect.bisect_left(s, self._size) - 1] 305 | larger = s[bisect.bisect_right(s, self._size)] 306 | estimate = (ri_dict[larger] - ri_dict[smaller]) / (larger - smaller) 307 | random_index = estimate * (self._size - smaller) + ri_dict[smaller] 308 | 309 | # Find the Perron-Frobenius eigenvalue of the matrix 310 | lambda_max = np.max(np.linalg.eigvals(self._matrix)) 311 | consistency_index = (lambda_max - self._size) / (self._size - 1) 312 | # The absolute value avoids confusion in those rare cases where a small negative float is rounded to -0.0 313 | self.consistency_ratio = np.abs(np.real(consistency_index / random_index).round(self.precision)) 314 | 315 | def add_children(self, children): 316 | """ 317 | Sets the input Compare objects as children of the current Compare object, assigns itself as their parent, 318 | then updates the global and target weights of the new hierarchy. 319 | NB: A child Compare object's name MUST be included as an element of the current Compare object. 320 | :param children: list or tuple, Compare objects to form the children of the current Compare object 321 | """ 322 | self._node_children = children 323 | self._check_children() 324 | for child in self._node_children: 325 | child._node_parent = self 326 | self._recompute() 327 | 328 | def _check_children(self): 329 | """ 330 | Raises a TypeError if an input child is not a Compare object. 331 | """ 332 | for child in self._node_children: 333 | if not isinstance(child, Compare): 334 | msg = 'A Compare object is either misconfigured or missing from the hierarchy.' 335 | raise TypeError(msg) 336 | 337 | def _recompute(self): 338 | """ 339 | Calls all functions necessary for building the target weights of the Compare object, 340 | given its children, as well as updating the global weights of the Compare object's descendants. 341 | Then calls the same function on the current Compare object's parent. 342 | """ 343 | self._set_node_precision() 344 | self._compute_node_weights() 345 | self._set_target_weights() 346 | self._compute_global_and_local_weights() 347 | if self._node_parent: 348 | self._node_parent._recompute() 349 | 350 | def _set_node_precision(self): 351 | """ 352 | Sets the '_node_precision' property of the Compare object by selecting the lowest precision of its children. 353 | """ 354 | lowest_precision = np.min([child._node_precision for child in self._node_children]) 355 | if lowest_precision < self.precision: 356 | self._node_precision = lowest_precision 357 | else: 358 | self._node_precision = self.precision 359 | 360 | def _compute_node_weights(self): 361 | """ 362 | Builds the '_node_weights' dictionary of the Compare object, given the target weights of its children. 363 | """ 364 | self._node_weights = dict() 365 | for parent_key, parent_value in self.local_weights.items(): 366 | for child in self._node_children: 367 | if parent_key == child.name: 368 | for child_key, child_value in child._node_weights.items(): 369 | value = parent_value * child_value 370 | try: 371 | self._node_weights[child_key] += value 372 | except KeyError: 373 | self._node_weights[child_key] = value 374 | break 375 | self._node_weights = dict(sorted(self._node_weights.items(), key=lambda item: item[1], reverse=True)) 376 | self._node_weights = {key: value.round(self._node_precision) for key, value in self._node_weights.items()} 377 | 378 | def _set_target_weights(self): 379 | """ 380 | Removes the 'target_weights' property of all children, then resets the property of the current Compare object. 381 | """ 382 | for child in self._node_children: 383 | child.target_weights = None 384 | self.target_weights = self._node_weights 385 | 386 | def _compute_global_and_local_weights(self): 387 | """ 388 | Recursively updates both the global and local weights of the Compare object's immediate descendants. 389 | """ 390 | if self._node_children: 391 | for parent_key, parent_value in self.local_weights.items(): 392 | for child in self._node_children: 393 | if parent_key == child.name: 394 | child.global_weight = np.round(self.global_weight * parent_value, self.precision) 395 | child.local_weight = parent_value 396 | child._apply_weight() 397 | child._compute_global_and_local_weights() 398 | break 399 | 400 | def _apply_weight(self): 401 | """ 402 | Updates the 'global_weights' dictionary of the Compare object, given the global weight of the node. 403 | """ 404 | for key in self.global_weights: 405 | self.global_weights[key] = np.round(self.global_weight * self.local_weights[key], self.precision) 406 | 407 | def _get_report(self, params): 408 | """ 409 | Climbs to the top of the hierarchy, then calls '_build_report()'. 410 | :param params: tuple, a nested dictionary containing reports and a boolean for the verbose argument 411 | """ 412 | if self.global_weight != 1.0: 413 | self._node_parent._get_report(params) 414 | else: 415 | return self._build_report(params) 416 | 417 | def _build_report(self, params): 418 | """ 419 | Creates a standard or verbose report for the Compare object, then calls itself on all of the object's children. 420 | :param params: tuple, a nested dictionary containing reports and a boolean for the verbose argument 421 | """ 422 | 423 | def set_random_index(): 424 | """ 425 | Returns the full name of a valid random index as a string, else None. 426 | """ 427 | random_index = None 428 | if self.random_index == 'dd': 429 | random_index = 'Donegan & Dodd' 430 | elif self.random_index == 'saaty': 431 | random_index = 'Saaty' 432 | return random_index 433 | 434 | hierarchy, verbose = params 435 | 436 | hierarchy[self.name] = {'name': self.name, 437 | 'global_weight': self.global_weight, 438 | 'local_weight': self._node_parent.local_weights[ 439 | self.name] if self.global_weight != 1.0 else 1.0, 440 | 'target_weights': self._node_weights if self.global_weight == 1.0 else None, 441 | 'elements': { 442 | 'global_weights': self.global_weights, 443 | 'local_weights': self.local_weights, 444 | 'consistency_ratio': self.consistency_ratio 445 | } 446 | } 447 | if verbose: 448 | hierarchy[self.name]['elements'].update({'random_index': set_random_index(), 449 | 'count': len(self._elements), 450 | 'names': self._elements}) 451 | hierarchy[self.name].update({'children': { 452 | 'count': len(self._node_children), 453 | 'names': [child.name for child in self._node_children] 454 | } if self._node_children else None, 455 | 'comparisons': { 456 | 'count': len(self.comparisons) + len(self._missing_comparisons), 457 | 'input': self.comparisons, 458 | 'computed': self._missing_comparisons if self._missing_comparisons else None 459 | } 460 | }) 461 | if self._node_children: 462 | for child in self._node_children: 463 | child._build_report(params) 464 | 465 | return hierarchy 466 | 467 | def report(self, complete=False, show=False, verbose=False): 468 | """ 469 | Returns the key information of the Compare object as a dictionary, optionally prints to the console. 470 | :param complete: boolean, whether to return a report for every Compare object in the hierarchy; default is False 471 | :param show: boolean, whether to print the report to the console; default is False 472 | :param verbose: boolean, whether to include full details of the Compare object within the report; default is False 473 | """ 474 | 475 | def convert_keys_to_json_format(input_dict): 476 | """ 477 | Returns a dictionary with keys in JSON format. 478 | :param input_dict: dictionary, the dictionary to be converted 479 | """ 480 | try: 481 | comparisons = {} 482 | if not self._normalize: 483 | for key, value in input_dict.items(): 484 | comparisons[', '.join(key)] = value 485 | else: 486 | raise AttributeError 487 | except AttributeError: 488 | comparisons = input_dict 489 | return comparisons 490 | 491 | hierarchy = {} 492 | self._get_report((hierarchy, verbose)) 493 | if not complete: 494 | hierarchy = dict({'name': self.name}, **hierarchy[self.name]) 495 | 496 | if show: 497 | json_report = copy.deepcopy(hierarchy) 498 | if verbose: 499 | for comparison in ('input', 'computed'): 500 | if complete: 501 | for key in json_report.keys(): 502 | json_report[key]['comparisons'][comparison] = convert_keys_to_json_format( 503 | json_report[key]['comparisons'][comparison]) 504 | else: 505 | json_report['comparisons'][comparison] = convert_keys_to_json_format( 506 | json_report['comparisons'][comparison]) 507 | print(json.dumps(json_report, indent=4)) 508 | 509 | return hierarchy 510 | 511 | 512 | class Compose: 513 | """ 514 | This class provides an alternative way to build a hierarchy of Compare objects using a dictionary 515 | of parent-child relationships, as well as an alternative way to build Compare objects using a list or tuple 516 | of the necessary inputs. 517 | """ 518 | 519 | def __init__(self): 520 | self.nodes = [] 521 | self.hierarchy = None 522 | 523 | def __getitem__(self, item): 524 | return self._get_node(item) 525 | 526 | def __getattr__(self, item): 527 | return self._get_node(item) 528 | 529 | def _get_node(self, name): 530 | """ 531 | Returns the named Compare object. 532 | :param name: string, the name of the desired Compare object 533 | """ 534 | for node in self.nodes: 535 | if node.name == name: 536 | return node 537 | return None 538 | 539 | def add_comparisons(self, item, 540 | comparisons=None, precision=4, random_index='dd', iterations=100, tolerance=0.0001, cr=True): 541 | """ 542 | Adds Compare objects to a stored list of nodes. Input can be either one or more Compare objects, 543 | one or more lists or tuples containing the inputs necessary to create a Compare object, 544 | or the arguments necessary to create a Compare object. 545 | The method signature is intended to mimic that of the Compare class for this reason. 546 | :param item: Compare object, list or tuple, string, either one or more Compare objects (or the data required 547 | to create a Compare object, stored as a list or tuple) or the 'name' argument that forms 548 | the first parameter required to create a new Compare object 549 | :param comparisons: dictionary, the elements and values to be compared, provided in one of two forms: (i) each key is a tuple of two elements and 550 | each value is their pairwise comparison value, or (ii) each key is a single element and each value 551 | is that element's measured value; default is None 552 | Examples: (i) {('a', 'b'): 3, ('b', 'c'): 2}, (ii) {'a': 1.2, 'b': 2.3, 'c': 3.4} 553 | :param precision: integer, number of decimal places used when computing both the priority 554 | vector and the consistency ratio; default is 4 555 | :param random_index: string, the random index estimates used to compute the consistency ratio; 556 | see '_compute_consistency_ratio()' for more information regarding the different estimates; 557 | valid input: 'dd', 'saaty'; default is 'dd' 558 | :param iterations: integer, number of iterations before '_compute_priority_vector()' stops; 559 | default is 100 560 | :param tolerance: float, the stopping criteria for the cycling coordinates algorithm instantiated by 561 | '_complete_matrix()'; the algorithm stops when the difference between the norms of two cycles 562 | of coordinates is less than this value; default is 0.0001 563 | :param cr: boolean, whether to compute the priority vector's consistency ratio; default is True 564 | """ 565 | if isinstance(item, Compare): 566 | self.nodes.append(item) 567 | elif isinstance(item, (list, tuple)): 568 | for i in item: 569 | if isinstance(i, Compare): 570 | self.nodes.append(i) 571 | elif isinstance(i, str): 572 | self.nodes.append(Compare(*item)) 573 | break 574 | else: 575 | self.nodes.append(Compare(*i)) 576 | else: # item is a Compare object name 577 | self.nodes.append(Compare(item, comparisons, precision, random_index, iterations, tolerance, cr)) 578 | 579 | def add_hierarchy(self, hierarchy): 580 | """ 581 | Builds a hierarchy of the stored Compare objects according to the input. 582 | :param hierarchy: dictionary, a representation of the hierarchy in which each key of the dictionary 583 | is the name of a parent and each value is a list of names of one or more of its children 584 | Example: {'a': ['b', 'c'], 'b': ['d', 'e']} 585 | """ 586 | try: 587 | self.hierarchy = hierarchy 588 | for name in self.hierarchy.keys(): 589 | children = [self._get_node(child_name) for child_name in self.hierarchy[name]] 590 | self._get_node(name).add_children(children) 591 | except AttributeError: 592 | msg = 'All comparisons must be added to the Compose object before adding a hierarchy.' 593 | raise AttributeError(msg) 594 | 595 | def report(self, name=None, show=False, verbose=False): 596 | """ 597 | Returns the key information of the stored Compare objects as a dictionary, optionally prints to the console. 598 | :param name: string, the name of the Compare object report to return; if None, returns a complete report 599 | for the given hierarchy; default is None 600 | :param show: boolean, whether to print the report to the console; default is False 601 | :param verbose: boolean, whether to include full details of the Compare object within the report; 602 | default is False 603 | """ 604 | if name: 605 | report = self._get_node(name).report(complete=False, show=show, verbose=verbose) 606 | else: 607 | report = self._get_node(list(self.hierarchy.keys())[0]).report(complete=True, show=show, verbose=verbose) 608 | return report 609 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AHPy 2 | 3 | **AHPy** is an implementation of the Analytic Hierarchy Process ([AHP](https://en.wikipedia.org/wiki/Analytic_hierarchy_process)), a method used to structure, synthesize and evaluate the elements of a decision problem. Developed by [Thomas Saaty](http://www.creativedecisions.org/about/ThomasLSaaty.php) in the 1970s, AHP's broad use in fields well beyond that of operational research is a testament to its simple yet powerful combination of psychology and mathematics. 4 | 5 | AHPy attempts to provide a library that is not only simple to use, but also capable of intuitively working within the numerous conceptual frameworks to which the AHP can be applied. For this reason, general terms have been preferred to more specific ones within the programming interface. 6 | 7 | #### Installing AHPy 8 | 9 | AHPy is available on the Python Package Index ([PyPI](https://pypi.org/)): 10 | 11 | ``` 12 | python -m pip install ahpy 13 | ``` 14 | 15 | AHPy requires [Python 3.7+](https://www.python.org/), as well as [numpy](https://numpy.org/) and [scipy](https://scipy.org/). 16 | 17 | ## Table of Contents 18 | 19 | #### Examples 20 | 21 | [Relative consumption of drinks in the United States](#relative-consumption-of-drinks-in-the-united-states) 22 | 23 | [Choosing a leader](#choosing-a-leader) 24 | 25 | [Purchasing a vehicle](#purchasing-a-vehicle) 26 | 27 | [Purchasing a vehicle reprised: normalized weights and the Compose class](#purchasing-a-vehicle-reprised-normalized-weights-and-the-compose-class) 28 | 29 | 30 | #### Details 31 | 32 | [The Compare Class](#the-compare-class) 33 | 34 | [Compare.add_children()](#compareadd_children) 35 | 36 | [Compare.report()](#comparereport) 37 | 38 | [The Compose Class](#the-compose-class) 39 | 40 | [Compose.add_comparisons()](#composeadd_comparisons) 41 | 42 | [Compose.add_hierarchy()](#composeadd_hierarchy) 43 | 44 | [Compose.report()](#composereport) 45 | 46 | [A Note on Weights](#a-note-on-weights) 47 | 48 | [Missing Pairwise Comparisons](#missing-pairwise-comparisons) 49 | 50 | [Development and Testing](#development-and-testing) 51 | 52 | --- 53 | 54 | ## Examples 55 | 56 | The easiest way to learn how to use AHPy is to *see* it used, so this README begins with worked examples of gradually increasing complexity. 57 | 58 | ### Relative consumption of drinks in the United States 59 | 60 | This example is often used in Saaty's expositions of the AHP as a brief but clear demonstration of the method; it's what first opened my eyes to the broad usefulness of the AHP (as well as the wisdom of crowds!). The version I'm using here is from his 2008 article '[Decision making with the analytic hierarchy process](https://doi.org/10.1504/IJSSCI.2008.017590)'. If you're unfamiliar with the example, 30 participants were asked to compare the relative consumption of drinks in the United States. For instance, they believed that coffee was consumed *much* more than wine, but at the same rate as milk. The matrix derived from their answers was as follows: 61 | 62 | ||Coffee|Wine|Tea|Beer|Soda|Milk|Water| 63 | |-|:-:|:-:|:-:|:-:|:-:|:-:|:-:| 64 | |Coffee|1|9|5|2|1|1|1/2| 65 | |Wine|1/9|1|1/3|1/9|1/9|1/9|1/9| 66 | |Tea|1/5|3|1|1/3|1/4|1/3|1/9| 67 | |Beer|1/2|9|3|1|1/2|1|1/3| 68 | |Soda|1|9|4|2|1|2|1/2| 69 | |Milk|1|9|3|1|1/2|1|1/3| 70 | |Water|2|9|9|3|2|3|1| 71 | 72 | The table below shows the relative consumption of drinks as computed using the AHP, given this matrix, together with the *actual* relative consumption of drinks as obtained from U.S. Statistical Abstracts: 73 | 74 | |:exploding_head:|Coffee|Wine|Tea|Beer|Soda|Milk|Water| 75 | |-|:-:|:-:|:-:|:-:|:-:|:-:|:-:| 76 | |AHP|0.177|0.019|0.042|0.116|0.190|0.129|0.327| 77 | |Actual|0.180|0.010|0.040|0.120|0.180|0.140|0.330| 78 | 79 | We can recreate this analysis with AHPy using the following code: 80 | 81 | ```python 82 | >>> drink_comparisons = {('coffee', 'wine'): 9, ('coffee', 'tea'): 5, ('coffee', 'beer'): 2, ('coffee', 'soda'): 1, 83 | ('coffee', 'milk'): 1, ('coffee', 'water'): 1 / 2, 84 | ('wine', 'tea'): 1 / 3, ('wine', 'beer'): 1 / 9, ('wine', 'soda'): 1 / 9, 85 | ('wine', 'milk'): 1 / 9, ('wine', 'water'): 1 / 9, 86 | ('tea', 'beer'): 1 / 3, ('tea', 'soda'): 1 / 4, ('tea', 'milk'): 1 / 3, 87 | ('tea', 'water'): 1 / 9, 88 | ('beer', 'soda'): 1 / 2, ('beer', 'milk'): 1, ('beer', 'water'): 1 / 3, 89 | ('soda', 'milk'): 2, ('soda', 'water'): 1 / 2, 90 | ('milk', 'water'): 1 / 3} 91 | 92 | >>> drinks = ahpy.Compare(name='Drinks', comparisons=drink_comparisons, precision=3, random_index='saaty') 93 | 94 | >>> print(drinks.target_weights) 95 | {'water': 0.327, 'soda': 0.19, 'coffee': 0.177, 'milk': 0.129, 'beer': 0.116, 'tea': 0.042, 'wine': 0.019} 96 | 97 | >>> print(drinks.consistency_ratio) 98 | 0.022 99 | ``` 100 | 101 | 1. First, we create a dictionary of pairwise comparisons using the values from the matrix above.
102 | 2. We then create a **Compare** object, initializing it with a unique name and the dictionary we just made. We also change the precision and random index so that the results match those provided by Saaty.
103 | 3. Finally, we print the Compare object's target weights and consistency ratio to see the results of our analysis. 104 | 105 | Brilliant! 106 | 107 | ### Choosing a leader 108 | 109 | This example can be found [in an appendix to an older version of the Wikipedia entry for AHP](https://web.archive.org/web/20240724002837/https://en.wikipedia.org/wiki/Analytic_hierarchy_process_-_leader_example). The names have been changed in a nod to [the original saying](https://www.grammarphobia.com/blog/2009/06/tom-dick-and-harry-part-2.html), but the input comparison values remain the same. 110 | 111 | #### N.B. 112 | 113 | You may notice that in some cases AHPy's results will not match those on the Wikipedia page. This is not an error in AHPy's calculations, but rather a result of [the method used to compute the values shown in the Wikipedia examples](https://en.wikipedia.org/wiki/Analytic_hierarchy_process_–_car_example#Pairwise_comparing_the_criteria_with_respect_to_the_goal): 114 | 115 | > You can duplicate this analysis at this online demonstration site...**IMPORTANT: The demo site is designed for convenience, not accuracy. The priorities it returns may differ somewhat from those returned by rigorous AHP calculations.** 116 | 117 | In this example, we'll be judging job candidates by their experience, education, charisma and age. Therefore, we need to compare each potential leader to the others, given each criterion... 118 | 119 | ```python 120 | >>> experience_comparisons = {('Moll', 'Nell'): 1/4, ('Moll', 'Sue'): 4, ('Nell', 'Sue'): 9} 121 | >>> education_comparisons = {('Moll', 'Nell'): 3, ('Moll', 'Sue'): 1/5, ('Nell', 'Sue'): 1/7} 122 | >>> charisma_comparisons = {('Moll', 'Nell'): 5, ('Moll', 'Sue'): 9, ('Nell', 'Sue'): 4} 123 | >>> age_comparisons = {('Moll', 'Nell'): 1/3, ('Moll', 'Sue'): 5, ('Nell', 'Sue'): 9} 124 | ``` 125 | 126 | ...as well as compare the importance of each criterion to the others: 127 | 128 | ```python 129 | >>> criteria_comparisons = {('Experience', 'Education'): 4, ('Experience', 'Charisma'): 3, ('Experience', 'Age'): 7, 130 | ('Education', 'Charisma'): 1/3, ('Education', 'Age'): 3, 131 | ('Charisma', 'Age'): 5} 132 | ``` 133 | 134 | Before moving on, it's important to note that the *order* of the elements that form the dictionaries' keys is meaningful. For example, using Saaty's scale, the comparison `('Experience', 'Education'): 4` means that "Experience is *moderately+ more important than* Education." 135 | 136 | Now that we've created all of the necessary pairwise comparison dictionaries, we'll create their corresponding Compare objects and use the dictionaries as input: 137 | 138 | ```python 139 | >>> experience = ahpy.Compare('Experience', experience_comparisons, precision=3, random_index='saaty') 140 | >>> education = ahpy.Compare('Education', education_comparisons, precision=3, random_index='saaty') 141 | >>> charisma = ahpy.Compare('Charisma', charisma_comparisons, precision=3, random_index='saaty') 142 | >>> age = ahpy.Compare('Age', age_comparisons, precision=3, random_index='saaty') 143 | >>> criteria = ahpy.Compare('Criteria', criteria_comparisons, precision=3, random_index='saaty') 144 | ``` 145 | 146 | Notice that the names of the Experience, Education, Charisma and Age objects are repeated in the `criteria_comparisons` dictionary above. This is necessary in order to properly link the Compare objects together into a hierarchy, as shown next. 147 | 148 | In the final step, we need to link the Compare objects together into a hierarchy, such that Criteria is the *parent* object and the other objects form its *children*: 149 | 150 | ```python 151 | >>> criteria.add_children([experience, education, charisma, age]) 152 | ``` 153 | 154 | Now that the hierarchy represents the decision problem, we can print the target weights of the parent Criteria object to see the results of the analysis: 155 | 156 | ```python 157 | >>> print(criteria.target_weights) 158 | {'Nell': 0.493, 'Moll': 0.358, 'Sue': 0.15} 159 | ``` 160 | 161 | We can also print the local and global weights of the elements within any of the other Compare objects, as well as the consistency ratio of their comparisons: 162 | 163 | ```python 164 | >>> print(experience.local_weights) 165 | {'Nell': 0.717, 'Moll': 0.217, 'Sue': 0.066} 166 | 167 | >>> print(experience.consistency_ratio) 168 | 0.035 169 | 170 | >>> print(education.global_weights) 171 | {'Sue': 0.093, 'Moll': 0.024, 'Nell': 0.01} 172 | 173 | >>> print(education.consistency_ratio) 174 | 0.062 175 | ``` 176 | 177 | The global and local weights of the Compare objects themselves are likewise available: 178 | 179 | ```python 180 | >>> print(experience.global_weight) 181 | 0.548 182 | 183 | >>> print(education.local_weight) 184 | 0.127 185 | ``` 186 | 187 | Calling `report()` on a Compare object provides a standard way to learn information about the object. In the code below, the variable `report` contains a [Python dictionary](#comparereport) of important information, while the `show=True` argument prints the same information to the console in JSON format: 188 | 189 | ```python 190 | >>> report = criteria.report(show=True) 191 | { 192 | "Criteria": { 193 | "global_weight": 1.0, 194 | "local_weight": 1.0, 195 | "target_weights": { 196 | "Nell": 0.493, 197 | "Moll": 0.358, 198 | "Sue": 0.15 199 | }, 200 | "elements": { 201 | "global_weights": { 202 | "Experience": 0.548, 203 | "Charisma": 0.27, 204 | "Education": 0.127, 205 | "Age": 0.056 206 | }, 207 | "local_weights": { 208 | "Experience": 0.548, 209 | "Charisma": 0.27, 210 | "Education": 0.127, 211 | "Age": 0.056 212 | }, 213 | "consistency_ratio": 0.044 214 | } 215 | } 216 | } 217 | ``` 218 | 219 | ### Purchasing a vehicle 220 | 221 | This example can also be found [in an appendix to the Wikipedia entry for AHP](https://en.wikipedia.org/wiki/Analytic_hierarchy_process_–_car_example). Like before, in some cases AHPy's results will not match those on the Wikipedia page, even though the input comparison values are identical. To reiterate, this is due to a difference in methods, not an error in AHPy. 222 | 223 | In this example, we'll be choosing a vehicle to purchase based on its cost, safety, style and capacity. Cost will further depend on a combination of the vehicle's purchase price, fuel costs, maintenance costs and resale value; capacity will depend on a combination of the vehicle's cargo and passenger capacity. 224 | 225 | First, we compare the high-level criteria to one another: 226 | 227 | ```python 228 | >>> criteria_comparisons = {('Cost', 'Safety'): 3, ('Cost', 'Style'): 7, ('Cost', 'Capacity'): 3, 229 | ('Safety', 'Style'): 9, ('Safety', 'Capacity'): 1, 230 | ('Style', 'Capacity'): 1/7} 231 | ``` 232 | 233 | If we create a Compare object for the criteria, we can view its report: 234 | 235 | ```python 236 | >>> criteria = ahpy.Compare('Criteria', criteria_comparisons, precision=3) 237 | >>> report = criteria.report(show=True) 238 | { 239 | "Criteria": { 240 | "global_weight": 1.0, 241 | "local_weight": 1.0, 242 | "target_weights": { 243 | "Cost": 0.51, 244 | "Safety": 0.234, 245 | "Capacity": 0.215, 246 | "Style": 0.041 247 | }, 248 | "elements": { 249 | "global_weights": { 250 | "Cost": 0.51, 251 | "Safety": 0.234, 252 | "Capacity": 0.215, 253 | "Style": 0.041 254 | }, 255 | "local_weights": { 256 | "Cost": 0.51, 257 | "Safety": 0.234, 258 | "Capacity": 0.215, 259 | "Style": 0.041 260 | }, 261 | "consistency_ratio": 0.08 262 | } 263 | } 264 | } 265 | ``` 266 | 267 | Next, we compare the *sub*criteria of Cost to one another... 268 | 269 | ```python 270 | >>> cost_comparisons = {('Price', 'Fuel'): 2, ('Price', 'Maintenance'): 5, ('Price', 'Resale'): 3, 271 | ('Fuel', 'Maintenance'): 2, ('Fuel', 'Resale'): 2, 272 | ('Maintenance', 'Resale'): 1/2} 273 | ``` 274 | 275 | ...as well as the subcriteria of Capacity: 276 | 277 | ```python 278 | >>> capacity_comparisons = {('Cargo', 'Passenger'): 1/5} 279 | ``` 280 | 281 | We also need to compare each of the potential vehicles to the others, given each criterion. We'll begin by building a list of all possible two-vehicle combinations: 282 | 283 | ```python 284 | >>> import itertools 285 | >>> vehicles = ('Accord Sedan', 'Accord Hybrid', 'Pilot', 'CR-V', 'Element', 'Odyssey') 286 | >>> vehicle_pairs = list(itertools.combinations(vehicles, 2)) 287 | >>> print(vehicle_pairs) 288 | [('Accord Sedan', 'Accord Hybrid'), ('Accord Sedan', 'Pilot'), ('Accord Sedan', 'CR-V'), ('Accord Sedan', 'Element'), ('Accord Sedan', 'Odyssey'), ('Accord Hybrid', 'Pilot'), ('Accord Hybrid', 'CR-V'), ('Accord Hybrid', 'Element'), ('Accord Hybrid', 'Odyssey'), ('Pilot', 'CR-V'), ('Pilot', 'Element'), ('Pilot', 'Odyssey'), ('CR-V', 'Element'), ('CR-V', 'Odyssey'), ('Element', 'Odyssey')] 289 | ``` 290 | 291 | Then we can simply zip together the vehicle pairs and their pairwise comparison values for each criterion: 292 | 293 | ```python 294 | >>> price_values = (9, 9, 1, 1/2, 5, 1, 1/9, 1/9, 1/7, 1/9, 1/9, 1/7, 1/2, 5, 6) 295 | >>> price_comparisons = dict(zip(vehicle_pairs, price_values)) 296 | >>> print(price_comparisons) 297 | {('Accord Sedan', 'Accord Hybrid'): 9, ('Accord Sedan', 'Pilot'): 9, ('Accord Sedan', 'CR-V'): 1, ('Accord Sedan', 'Element'): 0.5, ('Accord Sedan', 'Odyssey'): 5, ('Accord Hybrid', 'Pilot'): 1, ('Accord Hybrid', 'CR-V'): 0.1111111111111111, ('Accord Hybrid', 'Element'): 0.1111111111111111, ('Accord Hybrid', 'Odyssey'): 0.14285714285714285, ('Pilot', 'CR-V'): 0.1111111111111111, ('Pilot', 'Element'): 0.1111111111111111, ('Pilot', 'Odyssey'): 0.14285714285714285, ('CR-V', 'Element'): 0.5, ('CR-V', 'Odyssey'): 5, ('Element', 'Odyssey'): 6} 298 | 299 | >>> safety_values = (1, 5, 7, 9, 1/3, 5, 7, 9, 1/3, 2, 9, 1/8, 2, 1/8, 1/9) 300 | >>> safety_comparisons = dict(zip(vehicle_pairs, safety_values)) 301 | 302 | >>> passenger_values = (1, 1/2, 1, 3, 1/2, 1/2, 1, 3, 1/2, 2, 6, 1, 3, 1/2, 1/6) 303 | >>> passenger_comparisons = dict(zip(vehicle_pairs, passenger_values)) 304 | 305 | >>> fuel_values = (1/1.13, 1.41, 1.15, 1.24, 1.19, 1.59, 1.3, 1.4, 1.35, 1/1.23, 1/1.14, 1/1.18, 1.08, 1.04, 1/1.04) 306 | >>> fuel_comparisons = dict(zip(vehicle_pairs, fuel_values)) 307 | 308 | >>> resale_values = (3, 4, 1/2, 2, 2, 2, 1/5, 1, 1, 1/6, 1/2, 1/2, 4, 4, 1) 309 | >>> resale_comparisons = dict(zip(vehicle_pairs, resale_values)) 310 | 311 | >>> maintenance_values = (1.5, 4, 4, 4, 5, 4, 4, 4, 5, 1, 1.2, 1, 1, 3, 2) 312 | >>> maintenance_comparisons = dict(zip(vehicle_pairs, maintenance_values)) 313 | 314 | >>> style_values = (1, 7, 5, 9, 6, 7, 5, 9, 6, 1/6, 3, 1/3, 7, 5, 1/5) 315 | >>> style_comparisons = dict(zip(vehicle_pairs, style_values)) 316 | 317 | >>> cargo_values = (1, 1/2, 1/2, 1/2, 1/3, 1/2, 1/2, 1/2, 1/3, 1, 1, 1/2, 1, 1/2, 1/2) 318 | >>> cargo_comparisons = dict(zip(vehicle_pairs, cargo_values)) 319 | ``` 320 | 321 | Now that we've created all of the necessary pairwise comparison dictionaries, we can create their corresponding Compare objects: 322 | 323 | ```python 324 | >>> cost = ahpy.Compare('Cost', cost_comparisons, precision=3) 325 | >>> capacity = ahpy.Compare('Capacity', capacity_comparisons, precision=3) 326 | >>> price = ahpy.Compare('Price', price_comparisons, precision=3) 327 | >>> safety = ahpy.Compare('Safety', safety_comparisons, precision=3) 328 | >>> passenger = ahpy.Compare('Passenger', passenger_comparisons, precision=3) 329 | >>> fuel = ahpy.Compare('Fuel', fuel_comparisons, precision=3) 330 | >>> resale = ahpy.Compare('Resale', resale_comparisons, precision=3) 331 | >>> maintenance = ahpy.Compare('Maintenance', maintenance_comparisons, precision=3) 332 | >>> style = ahpy.Compare('Style', style_comparisons, precision=3) 333 | >>> cargo = ahpy.Compare('Cargo', cargo_comparisons, precision=3) 334 | ``` 335 | 336 | The final step is to link all of the Compare objects into a hierarchy. First, we'll make the Price, Fuel, Maintenance and Resale objects the children of the Cost object... 337 | 338 | ```python 339 | >>> cost.add_children([price, fuel, maintenance, resale]) 340 | ``` 341 | 342 | ...and do the same to link the Cargo and Passenger objects to the Capacity object... 343 | 344 | ```python 345 | >>> capacity.add_children([cargo, passenger]) 346 | ``` 347 | 348 | ...then finally make the Cost, Safety, Style and Capacity objects the children of the Criteria object: 349 | 350 | ```python 351 | >>> criteria.add_children([cost, safety, style, capacity]) 352 | ``` 353 | 354 | Now that the hierarchy represents the decision problem, we can print the target weights of the *highest level* Criteria object to see the results of the analysis: 355 | 356 | ```python 357 | >>> print(criteria.target_weights) 358 | {'Odyssey': 0.219, 'Accord Sedan': 0.215, 'CR-V': 0.167, 'Accord Hybrid': 0.15, 'Element': 0.144, 'Pilot': 0.106} 359 | ``` 360 | 361 | For detailed information about any of the Compare objects in the hierarchy, we can call that object's `report()` with the `verbose=True` argument: 362 | 363 | ```python 364 | >>> report = criteria.report(show=True, verbose=True) 365 | { 366 | "name": "Criteria", 367 | "global_weight": 1.0, 368 | "local_weight": 1.0, 369 | "target_weights": { 370 | "Odyssey": 0.219, 371 | "Accord Sedan": 0.215, 372 | "CR-V": 0.167, 373 | "Accord Hybrid": 0.15, 374 | "Element": 0.144, 375 | "Pilot": 0.106 376 | }, 377 | "elements": { 378 | "global_weights": { 379 | "Cost": 0.51, 380 | "Safety": 0.234, 381 | "Capacity": 0.215, 382 | "Style": 0.041 383 | }, 384 | "local_weights": { 385 | "Cost": 0.51, 386 | "Safety": 0.234, 387 | "Capacity": 0.215, 388 | "Style": 0.041 389 | }, 390 | "consistency_ratio": 0.08, 391 | "random_index": "Donegan & Dodd", 392 | "count": 4, 393 | "names": [ 394 | "Cost", 395 | "Safety", 396 | "Style", 397 | "Capacity" 398 | ] 399 | }, 400 | "children": { 401 | "count": 4, 402 | "names": [ 403 | "Cost", 404 | "Safety", 405 | "Style", 406 | "Capacity" 407 | ] 408 | }, 409 | "comparisons": { 410 | "count": 6, 411 | "input": { 412 | "Cost, Safety": 3, 413 | "Cost, Style": 7, 414 | "Cost, Capacity": 3, 415 | "Safety, Style": 9, 416 | "Safety, Capacity": 1, 417 | "Style, Capacity": 0.14285714285714285 418 | }, 419 | "computed": null 420 | } 421 | } 422 | ``` 423 | 424 | Calling `report(show=True, verbose=True)` on Compare objects at lower levels of the hierarchy will provide different information, depending on the level they're in: 425 | 426 | ```python 427 | >>> report = cost.report(show=True, verbose=True) 428 | { 429 | "name": "Cost", 430 | "global_weight": 0.51, 431 | "local_weight": 0.51, 432 | "target_weights": null, 433 | "elements": { 434 | "global_weights": { 435 | "Price": 0.249, 436 | "Fuel": 0.129, 437 | "Resale": 0.082, 438 | "Maintenance": 0.051 439 | }, 440 | "local_weights": { 441 | "Price": 0.488, 442 | "Fuel": 0.252, 443 | "Resale": 0.161, 444 | "Maintenance": 0.1 445 | }, 446 | "consistency_ratio": 0.016, 447 | "random_index": "Donegan & Dodd", 448 | "count": 4, 449 | "names": [ 450 | "Price", 451 | "Fuel", 452 | "Maintenance", 453 | "Resale" 454 | ] 455 | }, 456 | "children": { 457 | "count": 4, 458 | "names": [ 459 | "Price", 460 | "Fuel", 461 | "Resale", 462 | "Maintenance" 463 | ] 464 | }, 465 | "comparisons": { 466 | "count": 6, 467 | "input": { 468 | "Price, Fuel": 2, 469 | "Price, Maintenance": 5, 470 | "Price, Resale": 3, 471 | "Fuel, Maintenance": 2, 472 | "Fuel, Resale": 2, 473 | "Maintenance, Resale": 0.5 474 | }, 475 | "computed": null 476 | } 477 | } 478 | 479 | >>> report = price.report(show=True, verbose=True) 480 | { 481 | "name": "Price", 482 | "global_weight": 0.249, 483 | "local_weight": 0.488, 484 | "target_weights": null, 485 | "elements": { 486 | "global_weights": { 487 | "Element": 0.091, 488 | "Accord Sedan": 0.061, 489 | "CR-V": 0.061, 490 | "Odyssey": 0.023, 491 | "Accord Hybrid": 0.006, 492 | "Pilot": 0.006 493 | }, 494 | "local_weights": { 495 | "Element": 0.366, 496 | "Accord Sedan": 0.246, 497 | "CR-V": 0.246, 498 | "Odyssey": 0.093, 499 | "Accord Hybrid": 0.025, 500 | "Pilot": 0.025 501 | }, 502 | "consistency_ratio": 0.072, 503 | "random_index": "Donegan & Dodd", 504 | "count": 6, 505 | "names": [ 506 | "Accord Sedan", 507 | "Accord Hybrid", 508 | "Pilot", 509 | "CR-V", 510 | "Element", 511 | "Odyssey" 512 | ] 513 | }, 514 | "children": null, 515 | "comparisons": { 516 | "count": 15, 517 | "input": { 518 | "Accord Sedan, Accord Hybrid": 9, 519 | "Accord Sedan, Pilot": 9, 520 | "Accord Sedan, CR-V": 1, 521 | "Accord Sedan, Element": 0.5, 522 | "Accord Sedan, Odyssey": 5, 523 | "Accord Hybrid, Pilot": 1, 524 | "Accord Hybrid, CR-V": 0.1111111111111111, 525 | "Accord Hybrid, Element": 0.1111111111111111, 526 | "Accord Hybrid, Odyssey": 0.14285714285714285, 527 | "Pilot, CR-V": 0.1111111111111111, 528 | "Pilot, Element": 0.1111111111111111, 529 | "Pilot, Odyssey": 0.14285714285714285, 530 | "CR-V, Element": 0.5, 531 | "CR-V, Odyssey": 5, 532 | "Element, Odyssey": 6 533 | }, 534 | "computed": null 535 | } 536 | } 537 | ``` 538 | 539 | Finally, calling `report(complete=True)` on any Compare object in the hierarchy will return a dictionary containing a report for *every* Compare object in the hierarchy, with the keys of the dictionary being the names of the Compare objects: 540 | 541 | ```python 542 | >>> complete_report = cargo.report(complete=True) 543 | 544 | >>> print([key for key in complete_report]) 545 | ['Criteria', 'Cost', 'Price', 'Fuel', 'Maintenance', 'Resale', 'Safety', 'Style', 'Capacity', 'Cargo', 'Passenger'] 546 | 547 | >>> print(complete_report['Cargo']) 548 | {'name': 'Cargo', 'global_weight': 0.0358, 'local_weight': 0.1667, 'target_weights': None, 'elements': {'global_weights': {'Odyssey': 0.011, 'Pilot': 0.006, 'CR-V': 0.006, 'Element': 0.006, 'Accord Sedan': 0.003, 'Accord Hybrid': 0.003}, 'local_weights': {'Odyssey': 0.311, 'Pilot': 0.17, 'CR-V': 0.17, 'Element': 0.17, 'Accord Sedan': 0.089, 'Accord Hybrid': 0.089}, 'consistency_ratio': 0.002}} 549 | 550 | >>> print(complete_report['Criteria']['target_weights']) 551 | {'Odyssey': 0.219, 'Accord Sedan': 0.215, 'CR-V': 0.167, 'Accord Hybrid': 0.15, 'Element': 0.144, 'Pilot': 0.106} 552 | ``` 553 | 554 | Calling `report(complete=True, verbose=True)` will return a similar dictionary, but with the detailed version of the reports. 555 | 556 | ```python 557 | >>> complete_report = style.report(complete=True, verbose=True) 558 | 559 | >>> print(complete_report['Price']['comparisons']['count']) 560 | 15 561 | ``` 562 | 563 | We could also print all of the reports to the console with the `show=True` argument. 564 | 565 | ### Purchasing a vehicle reprised: normalized weights and the Compose class 566 | 567 | After reading through the explanation of the [vehicle decision problem on Wikipedia](https://en.wikipedia.org/wiki/Analytic_hierarchy_process_–_car_example), you may have wondered whether the data used to represent purely numeric criteria (such as passenger capacity) could be used *directly* when comparing the vehicles to one another, rather than requiring tranformation into judgments of "intensity." In this example, we'll solve the same decision problem as before, except now we'll normalize the measured values for passenger capacity, fuel costs, resale value and cargo capacity in order to arrive at a different set of weights for these criteria. 568 | 569 | We'll also use a **Compose** object to structure the decision problem. The Compose object allows us to work with an abstract representation of the problem hierarchy, rather than build it dynamically with code, which is valuable when we're not using AHPy in an interactive setting. To use the Compose object, we'll need to first add the comparison information, then the hierarchy, *in that order*. But more on that later. 570 | 571 | Using the list of vehicles from the previous example, we'll first zip together the vehicles and their measured values, then create a Compare object for each of our normalized criteria: 572 | 573 | ```python 574 | >>> passenger_measured_values = (5, 5, 8, 5, 4, 8) 575 | >>> passenger_data = dict(zip(vehicles, passenger_measured_values)) 576 | >>> print(passenger_data) 577 | {'Accord Sedan': 5, 'Accord Hybrid': 5, 'Pilot': 8, 'CR-V': 5, 'Element': 4, 'Odyssey': 8} 578 | 579 | >>> passenger_normalized = ahp.Compare('Passenger', passenger_data, precision=3) 580 | 581 | >>> fuel_measured_values = (31, 35, 22, 27, 25, 26) 582 | >>> fuel_data = dict(zip(vehicles, fuel_measured_values)) 583 | >>> fuel_normalized = ahp.Compare('Fuel', fuel_data, precision=3) 584 | 585 | >>> resale_measured_values = (0.52, 0.46, 0.44, 0.55, 0.48, 0.48) 586 | >>> resale_data = dict(zip(vehicles, resale_measured_values)) 587 | >>> resale_normalized = ahp.Compare('Resale', resale_data, precision=3) 588 | 589 | >>> cargo_measured_values = (14, 14, 87.6, 72.9, 74.6, 147.4) 590 | >>> cargo_data = dict(zip(vehicles, cargo_measured_values)) 591 | >>> cargo_normalized = ahp.Compare('Cargo', cargo_data, precision=3) 592 | ``` 593 | 594 | Let's print the normalized local weights of the new Passenger object to compare them to the local weights in the previous example: 595 | 596 | ```python 597 | >>> print(passenger_normalized.local_weights) 598 | {'Pilot': 0.229, 'Odyssey': 0.229, 'Accord Sedan': 0.143, 'Accord Hybrid': 0.143, 'CR-V': 0.143, 'Element': 0.114} 599 | 600 | >>> print(passenger.local_weights) 601 | {'Accord Sedan': 0.493, 'Accord Hybrid': 0.197, 'Odyssey': 0.113, 'Element': 0.091, 'CR-V': 0.057, 'Pilot': 0.049} 602 | ``` 603 | 604 | When we use the measured values directly, we see that the rankings for the vehicles are different than they were before. Whether this will affect the *synthesized* rankings of the target variables remains to be seen, however. 605 | 606 | We next create a Compose object and begin to add the comparison information: 607 | 608 | ```python 609 | >>> compose = ahpy.Compose() 610 | 611 | >>> compose.add_comparisons([passenger_normalized, fuel_normalized, resale_normalized, cargo_normalized]) 612 | ``` 613 | 614 | We can add comparison information to the Compose object in a few different ways. As shown above, we can provide a list of Compare objects; we can also provide them one at a time or stored in a tuple. Using Compare objects from our previous example: 615 | 616 | ```python 617 | >>> compose.add_comparisons(cost) 618 | 619 | >>> compose.add_comparisons((safety, style, capacity)) 620 | ``` 621 | 622 | We can even treat the Compose object like a Compare object and add the data directly. Again using code from the previous example: 623 | 624 | ```python 625 | >>> compose.add_comparisons('Price', price_comparisons, precision=3) 626 | ``` 627 | 628 | Finally, we can provide an ordered list or tuple containing the data needed to construct a Compare object: 629 | 630 | ```python 631 | >>> comparisons = [('Maintenance', maintenance_comparisons, 3), ('Criteria', criteria_comparisons)] 632 | >>> compose.add_comparisons(comparisons) 633 | ``` 634 | 635 | Now that all of the comparison information has been added, we next need to create the hierarchy and add it to the Compose object. A hierarchy is simply a dictionary in which the keys are the names of *parent* Compare objects and the values are lists of the names of their *children*: 636 | 637 | ```python 638 | >>> hierarchy = {'Criteria': ['Cost', 'Safety', 'Style', 'Capacity'], 639 | 'Cost': ['Price', 'Fuel', 'Resale', 'Maintenance'], 640 | 'Capacity': ['Passenger', 'Cargo']} 641 | 642 | >>> compose.add_hierarchy(hierarchy) 643 | ``` 644 | 645 | With these two steps complete, we can now view the synthesized results of the analysis. 646 | 647 | We view a report for a Compose object in the same way we do for a Compare object. The only difference is that the Compose object displays a complete report by default; in order to view the report of a single Compare object in the hierarchy, we need to specify its name: 648 | 649 | ```python 650 | >>> criteria_report = compose.report('Criteria', show=True) 651 | { 652 | "name": "Criteria", 653 | "global_weight": 1.0, 654 | "local_weight": 1.0, 655 | "target_weights": { 656 | "Odyssey": 0.218, 657 | "Accord Sedan": 0.21, 658 | "Element": 0.161, 659 | "Accord Hybrid": 0.154, 660 | "CR-V": 0.149, 661 | "Pilot": 0.108 662 | }, 663 | "elements": { 664 | "global_weights": { 665 | "Cost": 0.51, 666 | "Safety": 0.234, 667 | "Capacity": 0.215, 668 | "Style": 0.041 669 | }, 670 | "local_weights": { 671 | "Cost": 0.51, 672 | "Safety": 0.234, 673 | "Capacity": 0.215, 674 | "Style": 0.041 675 | }, 676 | "consistency_ratio": 0.08 677 | } 678 | } 679 | ``` 680 | 681 | We can access the public properties of the comparison information we've added to the Compose object using either dot or bracket notation: 682 | 683 | ```python 684 | >>> print(compose.Criteria.target_weights) 685 | {'Odyssey': 0.218, 'Accord Sedan': 0.21, 'Element': 0.161, 'Accord Hybrid': 0.154, 'CR-V': 0.149, 'Pilot': 0.108} 686 | 687 | >>> print(compose['Resale']['local_weights']) 688 | {'CR-V': 0.188, 'Accord Sedan': 0.177, 'Element': 0.164, 'Odyssey': 0.164, 'Accord Hybrid': 0.157, 'Pilot': 0.15} 689 | ``` 690 | 691 | We can see that normalizing the numeric criteria leads to a slightly different set of target weights, though the Odyssey and the Accord Sedan remain the top two vehicles to consider for purchase. 692 | 693 | ## Details 694 | 695 | Keep reading to learn the details of the AHPy library's API... 696 | 697 | ### The Compare Class 698 | 699 | The Compare class computes the weights and consistency ratio of a positive reciprocal matrix, created using an input dictionary of pairwise comparison values. Optimal values are computed for any [missing pairwise comparisons](#missing-pairwise-comparisons). Compare objects can also be [linked together to form a hierarchy](#compareadd_children) representing the decision problem: the target weights of the problem elements are then derived by synthesizing all levels of the hierarchy. 700 | 701 | `Compare(name, comparisons, precision=4, random_index='dd', iterations=100, tolerance=0.0001, cr=True)` 702 | 703 | `name`: *str (required)*, the name of the Compare object 704 | - This property is used to link a child object to its parent and must be unique 705 | 706 | `comparisons`: *dict (required)*, the elements and values to be compared, provided in one of two forms: 707 | 708 | 1. A dictionary of pairwise comparisons, in which each key is a tuple of two elements and each value is their pairwise comparison value 709 | - `{('a', 'b'): 3, ('b', 'c'): 2, ('a', 'c'): 5}` 710 | - **The order of the elements in the key matters: the comparison `('a', 'b'): 3` means "a is moderately more important than b"** 711 | 712 | 2. A dictionary of measured values, in which each key is a single element and each value is that element's measured value 713 | - `{'a': 1.2, 'b': 2.3, 'c': 3.4}` 714 | - Given this form, AHPy will automatically create consistent, normalized target weights 715 | 716 | `precision`: *int*, the number of decimal places to take into account when computing both the target weights and the consistency ratio of the Compare object 717 | - The default precision value is 4 718 | 719 | `random_index`: *'dd'* or *'saaty'*, the set of random index estimates used to compute the Compare object's consistency ratio 720 | - 'dd' supports the computation of consistency ratios for matrices less than or equal to 100 × 100 in size and uses estimates from: 721 | 722 | >Donegan, H.A. and Dodd, F.J., 'A Note on Saaty's Random Indexes,' *Mathematical and Computer Modelling*, 15:10, 1991, pp. 135-137 (DOI: [10.1016/0895-7177(91)90098-R](https://doi.org/10.1016/0895-7177(91)90098-R)) 723 | - 'saaty' supports the computation of consistency ratios for matrices less than or equal to 15 × 15 in size and uses estimates from: 724 | 725 | >Saaty, T., *Theory And Applications Of The Analytic Network Process*, Pittsburgh: RWS Publications, 2005, p. 31 726 | - The default random index is 'dd' 727 | 728 | `iterations`: *int*, the stopping criterion for the algorithm used to compute the Compare object's target weights 729 | - If target weights have not been determined after this number of iterations, the algorithm stops and the last principal eigenvector to be computed is used as the target weights 730 | - The default number of iterations is 100 731 | 732 | `tolerance`: *float*, the stopping criterion for the cycling coordinates algorithm used to compute the optimal value of missing pairwise comparisons 733 | - The algorithm stops when the difference between the norms of two cycles of coordinates is less than this value 734 | - The default tolerance value is 0.0001 735 | 736 | `cr`: *bool*, whether to compute the target weights' consistency ratio 737 | - Set `cr=False` to compute the target weights of a matrix when a consistency ratio cannot be determined due to the size of the matrix 738 | - The default value is True 739 | 740 | The properties used to initialize the Compare class are intended to be accessed directly, along with a few others: 741 | 742 | `Compare.global_weight`: *float*, the global weight of the Compare object within the hierarchy 743 | 744 | `Compare.local_weight`: *float*, the local weight of the Compare object within the hierarchy 745 | 746 | `Compare.global_weights`: *dict*, the global weights of the Compare object's elements; each key is an element and each value is that element's computed global weight 747 | - `{'a': 0.25, 'b': 0.25}` 748 | 749 | `Compare.local_weights`: *dict*, the local weights of the Compare object's elements; each key is an element and each value is that element's computed local weight 750 | - `{'a': 0.5, 'b': 0.5}` 751 | 752 | `Compare.target_weights`: *dict*, the target weights of the elements in the lowest level of the hierarchy; each key is an element and each value is that element's computed target weight; *if the global weight of the Compare object is less than 1.0, the value will be `None`* 753 | - `{'a': 0.5, 'b': 0.5}` 754 | 755 | `Compare.consistency_ratio`: *float*, the consistency ratio of the Compare object's pairwise comparisons 756 | 757 | ### Compare.add_children() 758 | 759 | Compare objects can be linked together to form a hierarchy representing the decision problem. To link Compare objects together into a hierarchy, call `add_children()` on the Compare object intended to form the *upper* level (the *parent*) and include as an argument a list or tuple of one or more Compare objects intended to form its *lower* level (the *children*). 760 | 761 | **In order to properly synthesize the levels of the hierarchy, the `name` of each child object MUST appear as an element in its parent object's input `comparisons` dictionary.** 762 | 763 | `Compare.add_children(children)` 764 | 765 | `children`: *list* or *tuple (required)*, the Compare objects that will form the lower level of the current Compare object 766 | 767 | ```python 768 | >>> child1 = ahpy.Compare(name='child1', ...) 769 | >>> child2 = ahpy.Compare(name='child2', ...) 770 | 771 | >>> parent = ahpy.Compare(name='parent', comparisons={('child1', 'child2'): 5}) 772 | >>> parent.add_children([child1, child2]) 773 | ``` 774 | 775 | The precision of the target weights is updated as the hierarchy is constructed: each time `add_children()` is called, the precision of the target weights is set to equal that of the Compare object with the lowest precision in the hierarchy. Because lower precision propagates up through the hierarchy, *the target weights will always have the same level of precision as the hierarchy's least precise Compare object*. This also means that it is possible for the precision of a Compare object's target weights to be different from the precision of its local and global weights. 776 | 777 | ### Compare.report() 778 | 779 | A standard report on the details of a Compare object is available. To return the report as a dictionary, call `report()` on the Compare object; to simultaneously print the information to the console in JSON format, set `show=True`. The report is available in two levels of detail; to return the most detailed report, set `verbose=True`. 780 | 781 | `Compare.report(complete=False, show=False, verbose=False)` 782 | 783 | `complete`: *bool*, whether to return a report for every Compare object in the hierarchy 784 | - This returns a dictionary of reports, with the keys of the dictionary being the names of the Compare objects 785 | - `{'a': {'name': 'a', ...}, 'b': {'name': 'b', ...}}` 786 | - The default value is False 787 | 788 | `show`: *bool*, whether to print the report to the console in JSON format 789 | - The default value is False 790 | 791 | `verbose`: *bool*, whether to include full details of the Compare object in the report 792 | - The default value is False 793 | 794 | The keys of the report take the following form: 795 | 796 | `name`: *str*, the name of the Compare object 797 | 798 | `global_weight`: *float*, the global weight of the Compare object within the hierarchy 799 | 800 | `local_weight`: *float*, the local weight of the Compare object within the hierarchy 801 | 802 | `target_weights`: *dict*, the target weights of the elements in the lowest level of the hierarchy; each key is an element and each value is that element's computed target weight 803 | - `{'a': 0.5, 'b': 0.5}` 804 | - *If the global weight of the Compare object is less than 1.0, the value will be `None`* 805 | 806 | `elements`: *dict*, information regarding the elements compared by the Compare object 807 | - `global_weights`: *dict*, the global weights of the Compare object's elements; each key is an element and each value is that element's computed global weight 808 | - `{'a': 0.25, 'b': 0.25}` 809 | - `local_weights`: *dict*, the local weights of the Compare object's elements; each key is an element and each value is that element's computed local weight 810 | - `{'a': 0.5, 'b': 0.5}` 811 | - `consistency_ratio`: *float*, the consistency ratio of the Compare object's pairwise comparisons 812 | 813 | The remaining dictionary keys are only displayed when `verbose=True`: 814 | 815 | - `random_index`: *'Donegan & Dodd' or 'Saaty'*, the random index used to compute the consistency ratio 816 | - `count`: *int*, the number of elements compared by the Compare object 817 | - `names`: *list*, the names of the elements compared by the Compare object 818 | 819 | `children`: *dict*, the children of the Compare object 820 | - `count`: *int*, the number of the Compare object's children 821 | - `names`: *list*, the names of the Compare object's children 822 | - If the Compare object has no children, the value will be `None` 823 | 824 | `comparisons`: *dict*, the comparisons of the Compare object 825 | - `count`: *int*, the number of comparisons made by the Compare object, *not counting reciprocal comparisons* 826 | - `input`: *dict*, the comparisons input to the Compare object; this is identical to the input `comparisons` dictionary 827 | - `computed`: *dict*, the comparisons computed by the Compare object; each key is a tuple of two elements and each value is their computed pairwise comparison value 828 | - `{('c', 'd'): 0.730297106886979}, ...}` 829 | - If the Compare object has no computed comparisons, the value will be `None` 830 | 831 | ### The Compose Class 832 | 833 | The Compose class can store and structure all of the information making up a decision problem. After first [adding comparison information](#composeadd_comparisons) to the object, then [adding the problem hierarchy](#composeadd_hierarchy), the analysis results of the multiple different Compare objects can be accessed through the single Compose object. 834 | 835 | `Compose()` 836 | 837 | After adding all necessary information, the public properties of any stored Compare object can be accessed directly through the Compose object using either dot or bracket notation: 838 | 839 | ```python 840 | >>> my_compose_object.a.global_weights 841 | 842 | >>> my_compose_object['a']['global_weights'] 843 | ``` 844 | 845 | ### Compose.add_comparisons() 846 | 847 | The comparison information of a decision problem can be added to a Compose object in any of the several ways listed below. Always add comparison information *before* adding the problem hierarchy. 848 | 849 | `Compose.add_comparisons(item, comparisons=None, precision=4, random_index='dd', iterations=100, tolerance=0.0001, cr=True)` 850 | 851 | `item`: *Compare object, list or tuple, or string (required)*, this argument allows for multiple input types: 852 | 853 | 1. A single Compare object 854 | - `Compare('a', comparisons=a, ...)` 855 | 856 | 2. A list or tuple of Compare objects 857 | - `[Compare('a', ...), Compare('b', ...)]` 858 | 859 | 3. The data necessary to create a Compare object 860 | - `'a', comparisons=a, precision=3, ...` 861 | - The method signature mimics that of the Compare class for this reason 862 | 863 | 4. A nested list or tuple of the data necessary to create a Compare object 864 | - `(('a', a, 3, ...), ('b', b, 3, ...))` 865 | 866 | All other arguments are identical to those of the [Compare class](#the-compare-class). 867 | 868 | ### Compose.add_hierarchy() 869 | 870 | The Compose class uses an abstract representation of the problem hierarchy to automatically link its Compare objects together. When a hierarchy is added, the elements of the decision problem are synthesized and the analysis results are immediately available for use or viewing. 871 | 872 | **`Compose.add_hierarchy()` should only be called AFTER all comparison information has been added to the Compose object.** 873 | 874 | `Compose.add_hierarchy(hierarchy)` 875 | 876 | `hierarchy`: *dict*, a representation of the hierarchy as a dictionary, in which the keys are the names of parent Compare objects and the values are lists of the names of their children 877 | - `{'a': ['b', 'c'], 'b': ['d', 'e']}` 878 | 879 | ### Compose.report() 880 | 881 | The standard report available for a Compare object can be accessed through the Compose object. Calling `report()` on a Compose object is equivalent to calling `report(complete=True)` on a Compare object and will return a dictionary of all the reports within the hierarchy; calling `report(name='a')` on a Compose object is equivalent to calling `a.report()` on the named Compare object. 882 | 883 | `Compose.report(name=None, show=False, verbose=False)` 884 | 885 | `name`: *str*, the name of a Compare object report to return; if None, returns a dictionary of reports, with the keys of the dictionary being the names of the Compare objects in the hierarchy 886 | - `{'a': {'name': 'a', ...}, 'b': {'name': 'b', ...}}` 887 | - The default value is None 888 | 889 | All other arguments are identical to the [Compare class's `report()` method](#comparereport). 890 | 891 | ### A Note on Weights 892 | 893 | Compare objects compute up to three kinds of weights for their elements: global weights, local weights and target weights. 894 | Compare objects also compute their own global and local weight, given their parent. 895 | 896 | - **Global** weights display the computed weights of a Compare object's elements **dependent on** that object's global weight within the current hierarchy 897 | - Global weights are derived by multiplying the local weights of the elements within a Compare object by that object's *own* global weight in the current hierarchy 898 | 899 | - **Local** weights display the computed weights of a Compare object's elements **independent of** that object's global weight within the current hierarchy 900 | - The local weights of the elements within a Compare object will always (approximately) sum to 1.0 901 | 902 | - **Target** weights display the synthesized weights of the problem elements described in the *lowest level* of the current hierarchy 903 | - Target weights are only available from the Compare object at the highest level of the hierarchy (*i.e.* the only Compare object without a parent) 904 | 905 | #### N.B. 906 | 907 | A Compare object that does not have a parent will have identical global and local weights; a Compare object that has neither a parent nor children will have identical global, local and target weights. 908 | 909 | In many instances, the sum of the local or target weights of a Compare object will not equal 1.0 *exactly*. This is due to rounding. If it's critical that the sum of the weights equals 1.0, it's recommended to simply divide the weights by their cumulative sum: `x = x / np.sum(x)`. Note, however, that the resulting values will contain a false level of precision, given their inputs. 910 | 911 | ### Missing Pairwise Comparisons 912 | 913 | When a Compare object is initialized, the elements forming the keys of the input `comparisons` dictionary are permuted. Permutations of elements that do not contain a value within the input `comparisons` dictionary are then optimally solved for using the cyclic coordinates algorithm described in: 914 | 915 | >Bozóki, S., Fülöp, J. and Rónyai, L., 'On optimal completion of incomplete pairwise comparison matrices,' *Mathematical and Computer Modelling*, 52:1–2, 2010, pp. 318-333 (DOI: [10.1016/j.mcm.2010.02.047](https://doi.org/10.1016/j.mcm.2010.02.047)) 916 | 917 | As the paper notes, "The number of *necessary* pairwise comparisons ... depends on the characteristics of the real decision problem and provides an exciting topic of future research" (29). In other words, don't rely on the algorithm to fill in a comparison dictionary that has a large number of missing values: it certainly might, but it also very well might not. **Caveat emptor!** 918 | 919 | The example below demonstrates this functionality of AHPy using the following matrix: 920 | 921 | ||a|b|c|d| 922 | |-|:-:|:-:|:-:|:-:| 923 | |a|1|1|5|2| 924 | |b|1|1|3|4| 925 | |c|1/5|1/3|1|**3/4**| 926 | |d|1/2|1/4|**4/3**|1| 927 | 928 | We'll first compute the target weights and consistency ratio for the complete matrix, then repeat the process after removing the **(c, d)** comparison marked in bold. We can view the computed value in the Compare object's detailed report: 929 | 930 | ```python 931 | >>> comparisons = {('a', 'b'): 1, ('a', 'c'): 5, ('a', 'd'): 2, 932 | ('b', 'c'): 3, ('b', 'd'): 4, 933 | ('c', 'd'): 3 / 4} 934 | 935 | >>> complete = ahpy.Compare('Complete', comparisons) 936 | 937 | >>> print(complete.target_weights) 938 | {'b': 0.3917, 'a': 0.3742, 'd': 0.1349, 'c': 0.0991} 939 | 940 | >>> print(complete.consistency_ratio) 941 | 0.0372 942 | 943 | >>> del comparisons[('c', 'd')] 944 | 945 | >>> missing_cd = ahpy.Compare('Missing_CD', comparisons) 946 | 947 | >>> print(missing_cd.target_weights) 948 | {'b': 0.392, 'a': 0.3738, 'd': 0.1357, 'c': 0.0985} 949 | 950 | >>> print(missing_cd.consistency_ratio) 951 | 0.0372 952 | 953 | >>> report = missing_cd.report(verbose=True) 954 | >>> print(report['comparisons']['computed']) 955 | {('c', 'd'): 0.7302971068355002} 956 | ``` 957 | 958 | ## Development and Testing 959 | 960 | To set up a development environment and run the included tests, you can use the following commands: 961 | 962 | ``` 963 | virtualenv .venv 964 | source .venv/bin/activate 965 | python -m pip install --editable . 966 | python -m pip install pytest 967 | pytest 968 | ``` -------------------------------------------------------------------------------- /tests/test_ahp.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | import pytest 4 | 5 | from src import ahpy 6 | 7 | # Example from Saaty, Thomas L., 'Decision making with the analytic hierarchy process,' 8 | # Int. J. Services Sciences, 1:1, 2008, pp. 83-98. 9 | 10 | drinks = {('coffee', 'wine'): 9, ('coffee', 'tea'): 5, ('coffee', 'beer'): 2, ('coffee', 'soda'): 1, 11 | ('coffee', 'milk'): 1, 12 | ('water', 'coffee'): 2, ('water', 'wine'): 9, ('water', 'tea'): 9, 13 | ('water', 'beer'): 3, ('water', 'soda'): 2, ('water', 'milk'): 3, 14 | ('tea', 'wine'): 3, 15 | ('beer', 'wine'): 9, ('beer', 'tea'): 3, ('beer', 'milk'): 1, 16 | ('soda', 'wine'): 9, ('soda', 'tea'): 4, ('soda', 'beer'): 2, ('soda', 'milk'): 2, 17 | ('milk', 'wine'): 9, ('milk', 'tea'): 3} 18 | 19 | 20 | def test_drinks_cr_saaty(): 21 | c = ahpy.Compare('Drinks', drinks, precision=3, random_index='saaty') 22 | assert c.consistency_ratio == 0.022 23 | 24 | 25 | def test_drinks_cr_dd(): 26 | c = ahpy.Compare('Drinks', drinks, precision=4, random_index='dd') 27 | assert c.consistency_ratio == 0.0235 28 | 29 | 30 | def test_drinks_weights_precision_3_saaty(): 31 | c = ahpy.Compare('Drinks', drinks, precision=3, random_index='saaty') 32 | assert c.local_weights == {'beer': 0.116, 'coffee': 0.177, 'milk': 0.129, 'soda': 0.190, 33 | 'tea': 0.042, 'water': 0.327, 'wine': 0.019} 34 | 35 | 36 | def test_drinks_weights_precision_4_dd(): 37 | c = ahpy.Compare('Drinks', drinks, precision=4, random_index='dd') 38 | assert c.local_weights == {'beer': 0.1164, 'coffee': 0.1775, 'milk': 0.1288, 'soda': 0.1896, 39 | 'tea': 0.0418, 'water': 0.3268, 'wine': 0.0191} 40 | 41 | 42 | # Example from Saaty, Thomas, L., Theory and Applications of the Analytic Network Process, 2005. 43 | 44 | criteria = {('Culture', 'Housing'): 3, ('Culture', 'Transportation'): 5, 45 | ('Family', 'Culture'): 5, ('Family', 'Housing'): 7, ('Family', 'Transportation'): 7, 46 | ('Housing', 'Transportation'): 3, 47 | ('Jobs', 'Culture'): 2, ('Jobs', 'Housing'): 4, ('Jobs', 'Transportation'): 7, 48 | ('Family', 'Jobs'): 1} 49 | 50 | culture = {('Bethesda', 'Pittsburgh'): 1, 51 | ('Boston', 'Bethesda'): 2, ('Boston', 'Pittsburgh'): 2.5, ('Boston', 'Santa Fe'): 1, 52 | ('Pittsburgh', 'Bethesda'): 1, 53 | ('Santa Fe', 'Bethesda'): 2, ('Santa Fe', 'Pittsburgh'): 2.5} 54 | 55 | family = {('Bethesda', 'Boston'): 2, ('Bethesda', 'Santa Fe'): 4, 56 | ('Boston', 'Santa Fe'): 2, 57 | ('Pittsburgh', 'Bethesda'): 3, ('Pittsburgh', 'Boston'): 8, ('Pittsburgh', 'Santa Fe'): 9} 58 | 59 | housing = {('Bethesda', 'Boston'): 5, ('Bethesda', 'Santa Fe'): 2.5, 60 | ('Pittsburgh', 'Bethesda'): 2, ('Pittsburgh', 'Boston'): 9, ('Pittsburgh', 'Santa Fe'): 7, 61 | ('Santa Fe', 'Boston'): 4} 62 | 63 | jobs = {('Bethesda', 'Pittsburgh'): 3, ('Bethesda', 'Santa Fe'): 4, 64 | ('Boston', 'Bethesda'): 2, ('Boston', 'Pittsburgh'): 6, ('Boston', 'Santa Fe'): 8, 65 | ('Pittsburgh', 'Santa Fe'): 1} 66 | 67 | transportation = {('Bethesda', 'Boston'): 1.5, 68 | ('Bethesda', 'Santa Fe'): 4, 69 | ('Boston', 'Santa Fe'): 2.5, 70 | ('Pittsburgh', 'Bethesda'): 2, 71 | ('Pittsburgh', 'Boston'): 3.5, 72 | ('Pittsburgh', 'Santa Fe'): 9} 73 | 74 | 75 | def test_cities_weights_saaty_precision_3(): 76 | cu = ahpy.Compare('Culture', culture, precision=3, random_index='Saaty') 77 | f = ahpy.Compare('Family', family, precision=3, random_index='Saaty') 78 | h = ahpy.Compare('Housing', housing, precision=3, random_index='Saaty') 79 | j = ahpy.Compare('Jobs', jobs, precision=3, random_index='Saaty') 80 | t = ahpy.Compare('Transportation', transportation, precision=3, random_index='Saaty') 81 | 82 | cr = ahpy.Compare('Goal', criteria, precision=3, random_index='Saaty') 83 | cr.add_children([cu, f, h, j, t]) 84 | 85 | assert cr.target_weights == {'Bethesda': 0.229, 'Boston': 0.275, 'Pittsburgh': 0.385, 'Santa Fe': 0.111} 86 | 87 | 88 | def test_cities_weights_dd_precision_4(): 89 | cu = ahpy.Compare('Culture', culture, precision=4) 90 | f = ahpy.Compare('Family', family, precision=4) 91 | h = ahpy.Compare('Housing', housing, precision=4) 92 | j = ahpy.Compare('Jobs', jobs, precision=4) 93 | t = ahpy.Compare('Transportation', transportation, precision=4) 94 | 95 | cr = ahpy.Compare('Goal', criteria, precision=4) 96 | cr.add_children([cu, f, h, j, t]) 97 | 98 | assert cr.target_weights == {'Bethesda': 0.2291, 'Boston': 0.2748, 'Pittsburgh': 0.3852, 'Santa Fe': 0.1110} 99 | 100 | 101 | def test_cities_target_weights(): 102 | cu = ahpy.Compare('Culture', culture, precision=4) 103 | f = ahpy.Compare('Family', family, precision=4) 104 | h = ahpy.Compare('Housing', housing, precision=4) 105 | j = ahpy.Compare('Jobs', jobs, precision=4) 106 | t = ahpy.Compare('Transportation', transportation, precision=4) 107 | 108 | cr = ahpy.Compare('Goal', criteria, precision=4) 109 | cr.add_children([cu, f, h, j, t]) 110 | 111 | assert t.target_weights is None 112 | 113 | 114 | # Examples from Bozóki, S., Fülöp, J. and Rónyai, L., 'On optimal completion of incomplete 115 | # pairwise comparison matrices,' Mathematical and Computer Modelling, 52:1–2, 2010, pp. 318-333. 116 | # https://doi.org/10.1016/j.mcm.2010.02.047 117 | 118 | u = {('a', 'b'): 1, ('a', 'c'): 5, ('a', 'd'): 2, 119 | ('b', 'c'): 3, ('b', 'd'): 4} 120 | 121 | 122 | def test_incomplete_example_missing_comparisons(): 123 | cu = ahpy.Compare('Incomplete Example', u) 124 | assert cu._missing_comparisons == pytest.approx({('c', 'd'): 0.730297106886979}) 125 | 126 | 127 | def test_incomplete_example_weights(): 128 | cu = ahpy.Compare('Incomplete Example', u) 129 | assert cu.local_weights == {'a': 0.3738, 'b': 0.392, 'c': 0.0985, 'd': 0.1357} 130 | 131 | 132 | def test_incomplete_example_cr(): 133 | cu = ahpy.Compare('Incomplete Example', u) 134 | assert cu.consistency_ratio == 0.0372 135 | 136 | 137 | def test_incomplete_housing_missing_comparisons(): 138 | m = {('a', 'b'): 5, ('a', 'c'): 3, ('a', 'd'): 7, ('a', 'e'): 6, ('a', 'f'): 6, 139 | ('b', 'd'): 5, ('b', 'f'): 3, 140 | ('c', 'e'): 3, ('c', 'g'): 6, 141 | ('f', 'd'): 4, 142 | ('g', 'a'): 3, ('g', 'e'): 5, 143 | ('h', 'a'): 4, ('h', 'b'): 7, ('h', 'd'): 8, ('h', 'f'): 6} 144 | cm = ahpy.Compare('Incomplete Housing', m) 145 | assert (cm._missing_comparisons == 146 | pytest.approx({('b', 'c'): 0.3300187496240363, ('b', 'e'): 1.7197409185349517, 147 | ('b', 'g'): 0.4663515002203321, ('c', 'd'): 9.920512661898753, 148 | ('c', 'f'): 4.852486449214693, ('c', 'h'): 0.5696073301509899, 149 | ('d', 'e'): 0.5252768142894285, ('d', 'g'): 0.1424438146531802, 150 | ('e', 'f'): 0.9311973564754218, ('e', 'h'): 0.10930828182051665, 151 | ('f', 'g'): 0.2912120796181874, ('g', 'h'): 0.4030898885178746})) 152 | 153 | 154 | # Example from Haas, R. and Meixner, L., 'An Illustrated Guide to the Analytic Hierarchy Process,' 155 | # http://www.inbest.co.il/NGO/ahptutorial.pdf 156 | 157 | def test_normalized_weights(): 158 | fuel = {'civic': 34, 'saturn': 27, 'escort': 24, 'clio': 28} 159 | cf = ahpy.Compare('Fuel Economy', fuel) 160 | assert cf.local_weights == {'civic': 0.3009, 'saturn': 0.2389, 'escort': 0.2124, 'clio': 0.2478} 161 | 162 | 163 | alphabet = 'abcdefghijklmnopqrstuvwxyz' 164 | values = {'a': 0.0385, 'b': 0.0385, 'c': 0.0385, 'd': 0.0385, 'e': 0.0385, 'f': 0.0385, 'g': 0.0385, 'h': 0.0385, 165 | 'i': 0.0385, 'j': 0.0385, 'k': 0.0385, 'l': 0.0385, 'm': 0.0385, 'n': 0.0385, 'o': 0.0385, 'p': 0.0385, 166 | 'q': 0.0385, 'r': 0.0385, 's': 0.0385, 't': 0.0385, 'u': 0.0385, 'v': 0.0385, 'w': 0.0385, 'x': 0.0385, 167 | 'y': 0.0385, 'z': 0.0385} 168 | 169 | 170 | def test_size_limit_saaty(): 171 | with pytest.raises(ValueError): 172 | x = dict.fromkeys(itertools.permutations(alphabet, 2), 1) 173 | ahpy.Compare('CR Test', x, random_index='saaty') 174 | 175 | 176 | def test_size_limit_override_saaty(): 177 | x = dict.fromkeys(itertools.permutations(alphabet, 2), 1) 178 | cx = ahpy.Compare('CR Test', x, random_index='saaty', cr=False) 179 | assert cx.local_weights == values 180 | 181 | 182 | def test_size_limit_normalize_saaty(): 183 | y = dict.fromkeys([i[0] for i in itertools.combinations(alphabet, 1)], 1) 184 | cy = ahpy.Compare('CR Test', y, random_index='saaty') 185 | assert cy.local_weights == values 186 | 187 | 188 | a_m = {('b', 'c'): 1} 189 | b_m = {('d', 'e'): 4} 190 | d_m = {('f', 'g'): 2} 191 | 192 | c_m = {'x': 2, 'y': 4, 'z': 4} 193 | e_m = {'x': 1, 'y': 2, 'z': 3} 194 | f_m = {'x': 2, 'y': 4, 'z': 4} 195 | g_m = {'x': 1, 'y': 2, 'z': 3} 196 | 197 | a = ahpy.Compare('a', a_m) 198 | b = ahpy.Compare('b', b_m) 199 | c = ahpy.Compare('c', c_m) 200 | d = ahpy.Compare('d', d_m) 201 | e = ahpy.Compare('e', e_m) 202 | f = ahpy.Compare('f', f_m) 203 | g = ahpy.Compare('g', g_m) 204 | 205 | a.add_children([b, c]) 206 | b.add_children([d, e]) 207 | d.add_children([f, g]) 208 | 209 | 210 | def test_master_a(): 211 | assert a.report(verbose=True) == {'name': 'a', 'global_weight': 1.0, 'local_weight': 1.0, 212 | 'target_weights': {'z': 0.4233, 'y': 0.3844, 'x': 0.1922}, 213 | 'elements': {'global_weights': {'b': 0.5, 'c': 0.5}, 214 | 'local_weights': {'b': 0.5, 'c': 0.5}, 'consistency_ratio': 0.0, 215 | 'random_index': 'Donegan & Dodd', 'count': 2, 'names': ['b', 'c']}, 216 | 'children': {'count': 2, 'names': ['b', 'c']}, 217 | 'comparisons': {'count': 1, 'input': {('b', 'c'): 1}, 'computed': None}} 218 | 219 | 220 | def test_master_b(): 221 | assert b.report(verbose=True) == {'name': 'b', 'global_weight': 0.5, 'local_weight': 0.5, 'target_weights': None, 222 | 'elements': {'global_weights': {'d': 0.4, 'e': 0.1}, 223 | 'local_weights': {'d': 0.8, 'e': 0.2}, 'consistency_ratio': 0.0, 224 | 'random_index': 'Donegan & Dodd', 'count': 2, 'names': ['d', 'e']}, 225 | 'children': {'count': 2, 'names': ['d', 'e']}, 226 | 'comparisons': {'count': 1, 'input': {('d', 'e'): 4}, 'computed': None}} 227 | 228 | 229 | def test_master_c(): 230 | assert c.report(verbose=True) == {'name': 'c', 'global_weight': 0.5, 'local_weight': 0.5, 'target_weights': None, 231 | 'elements': {'global_weights': {'y': 0.2, 'z': 0.2, 'x': 0.1}, 232 | 'local_weights': {'y': 0.4, 'z': 0.4, 'x': 0.2}, 233 | 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 234 | 'count': 3, 'names': ['x', 'y', 'z']}, 'children': None, 235 | 'comparisons': {'count': 3, 'input': {'x': 2, 'y': 4, 'z': 4}, 'computed': None}} 236 | 237 | 238 | def test_master_d(): 239 | assert d.report(verbose=True) == {'name': 'd', 'global_weight': 0.4, 'local_weight': 0.8, 'target_weights': None, 240 | 'elements': {'global_weights': {'f': 0.2667, 'g': 0.1333}, 241 | 'local_weights': {'f': 0.6667, 'g': 0.3333}, 242 | 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 243 | 'count': 2, 'names': ['f', 'g']}, 244 | 'children': {'count': 2, 'names': ['f', 'g']}, 245 | 'comparisons': {'count': 1, 'input': {('f', 'g'): 2}, 'computed': None}} 246 | 247 | 248 | def test_master_e(): 249 | assert e.report(verbose=True) == {'name': 'e', 'global_weight': 0.1, 'local_weight': 0.2, 'target_weights': None, 250 | 'elements': {'global_weights': {'z': 0.05, 'y': 0.0333, 'x': 0.0167}, 251 | 'local_weights': {'z': 0.5, 'y': 0.3333, 'x': 0.1667}, 252 | 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 253 | 'count': 3, 'names': ['x', 'y', 'z']}, 'children': None, 254 | 'comparisons': {'count': 3, 'input': {'x': 1, 'y': 2, 'z': 3}, 'computed': None}} 255 | 256 | 257 | def test_master_f(): 258 | assert f.report(verbose=True) == {'name': 'f', 'global_weight': 0.2667, 'local_weight': 0.6667, 259 | 'target_weights': None, 260 | 'elements': {'global_weights': {'y': 0.1067, 'z': 0.1067, 'x': 0.0533}, 261 | 'local_weights': {'y': 0.4, 'z': 0.4, 'x': 0.2}, 262 | 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 263 | 'count': 3, 'names': ['x', 'y', 'z']}, 'children': None, 264 | 'comparisons': {'count': 3, 'input': {'x': 2, 'y': 4, 'z': 4}, 'computed': None}} 265 | 266 | 267 | def test_master_g(): 268 | assert g.report(verbose=True) == {'name': 'g', 'global_weight': 0.1333, 'local_weight': 0.3333, 269 | 'target_weights': None, 270 | 'elements': {'global_weights': {'z': 0.0666, 'y': 0.0444, 'x': 0.0222}, 271 | 'local_weights': {'z': 0.5, 'y': 0.3333, 'x': 0.1667}, 272 | 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 273 | 'count': 3, 'names': ['x', 'y', 'z']}, 'children': None, 274 | 'comparisons': {'count': 3, 'input': {'x': 1, 'y': 2, 'z': 3}, 'computed': None}} 275 | 276 | 277 | # Example from https://en.wikipedia.org/wiki/Analytic_hierarchy_process_%E2%80%93_car_example 278 | 279 | cri = ('Cost', 'Safety', 'Style', 'Capacity') 280 | c_cri = list(itertools.combinations(cri, 2)) 281 | 282 | costs = ('Price', 'Fuel', 'Maintenance', 'Resale') 283 | c_pairs = list(itertools.combinations(costs, 2)) 284 | 285 | alt = ('Accord Sedan', 'Accord Hybrid', 'Pilot', 'CR-V', 'Element', 'Odyssey') 286 | pairs = list(itertools.combinations(alt, 2)) 287 | 288 | capacity_pass_m = (1, 1 / 2, 1, 3, 1 / 2, 1 / 2, 1, 3, 1 / 2, 2, 6, 1, 3, 1 / 2, 1 / 6) 289 | capacity_cargo_m = (1, 1 / 2, 1 / 2, 1 / 2, 1 / 3, 1 / 2, 1 / 2, 1 / 2, 1 / 3, 1, 1, 1 / 2, 1, 1 / 2, 1 / 2) 290 | cost_price_m = (9, 9, 1, 0.5, 5, 1, 1 / 9, 1 / 9, 1 / 7, 1 / 9, 1 / 9, 1 / 7, 1 / 2, 5, 6) 291 | cost_fuel_m = ( 292 | 1 / 1.13, 1.41, 1.15, 1.24, 1.19, 1.59, 1.3, 1.4, 1.35, 1 / 1.23, 1 / 1.14, 1 / 1.18, 1.08, 1.04, 1 / 1.04) 293 | cost_resale_m = (3, 4, 1 / 2, 2, 2, 2, 1 / 5, 1, 1, 1 / 6, 1 / 2, 1 / 2, 4, 4, 1) 294 | cost_maint_m = (1.5, 4, 4, 4, 5, 4, 4, 4, 5, 1, 1.2, 1, 1, 3, 2) 295 | safety_m = (1, 5, 7, 9, 1 / 3, 5, 7, 9, 1 / 3, 2, 9, 1 / 8, 2, 1 / 8, 1 / 9) 296 | style_m = (1, 7, 5, 9, 6, 7, 5, 9, 6, 1 / 6, 3, 1 / 3, 7, 5, 1 / 5) 297 | 298 | cost = ahpy.Compare('Cost', dict(zip(c_pairs, (2, 5, 3, 2, 2, .5)))) 299 | capacity = ahpy.Compare('Capacity', {('Cargo', 'Passenger'): 0.2}) 300 | capacity_cargo = ahpy.Compare('Cargo', dict(zip(pairs, capacity_cargo_m))) 301 | safety = ahpy.Compare('Safety', dict(zip(pairs, safety_m)), 3) 302 | style = ahpy.Compare('Style', dict(zip(pairs, style_m)), 3) 303 | 304 | h = {'Criteria': ['Cost', 'Safety', 'Style', 'Capacity'], 305 | 'Cost': ['Price', 'Fuel', 'Resale', 'Maintenance'], 306 | 'Capacity': ['Passenger', 'Cargo']} 307 | 308 | compose = ahpy.Compose() 309 | 310 | compose.add_comparisons(capacity_cargo) 311 | compose.add_comparisons([cost, capacity]) 312 | compose.add_comparisons((safety, style)) 313 | 314 | compose.add_comparisons('Criteria', dict(zip(c_cri, (3, 7, 3, 9, 1, 1 / 7))), 3) 315 | compose.add_comparisons(('Passenger', dict(zip(pairs, capacity_pass_m)))) 316 | 317 | compose.add_comparisons([('Price', dict(zip(pairs, cost_price_m)), 3), ('Fuel', dict(zip(pairs, cost_fuel_m)), 3)]) 318 | compose.add_comparisons( 319 | (['Resale', dict(zip(pairs, cost_resale_m)), 3], ['Maintenance', dict(zip(pairs, cost_maint_m)), 3, 'saaty'])) 320 | 321 | compose.add_hierarchy(h) 322 | 323 | 324 | def test_compose_target_weights_attr(): 325 | assert compose.Criteria.target_weights == {'Odyssey': 0.219, 'Accord Sedan': 0.215, 'CR-V': 0.167, 326 | 'Accord Hybrid': 0.15, 'Element': 0.144, 'Pilot': 0.106} 327 | 328 | 329 | def test_compose_item(): 330 | assert compose['Price']['local_weights'] == {'Element': 0.366, 'Accord Sedan': 0.246, 'CR-V': 0.246, 331 | 'Odyssey': 0.093, 'Accord Hybrid': 0.025, 'Pilot': 0.025} 332 | 333 | 334 | def test_compose_verbose_report(): 335 | assert compose.report(verbose=True) == {'Criteria': {'name': 'Criteria', 'global_weight': 1.0, 'local_weight': 1.0, 336 | 'target_weights': {'Odyssey': 0.219, 'Accord Sedan': 0.215, 337 | 'CR-V': 0.167, 'Accord Hybrid': 0.15, 338 | 'Element': 0.144, 'Pilot': 0.106}, 339 | 'elements': {'global_weights': {'Cost': 0.51, 'Safety': 0.234, 340 | 'Capacity': 0.215, 341 | 'Style': 0.041}, 342 | 'local_weights': {'Cost': 0.51, 'Safety': 0.234, 343 | 'Capacity': 0.215, 344 | 'Style': 0.041}, 345 | 'consistency_ratio': 0.08, 346 | 'random_index': 'Donegan & Dodd', 'count': 4, 347 | 'names': ['Cost', 'Safety', 'Style', 'Capacity']}, 348 | 'children': {'count': 4, 349 | 'names': ['Cost', 'Safety', 'Style', 'Capacity']}, 350 | 'comparisons': {'count': 6, 351 | 'input': pytest.approx({('Cost', 'Safety'): 3, 352 | ('Cost', 'Style'): 7, 353 | ( 354 | 'Cost', 355 | 'Capacity'): 3, 356 | ('Safety', 'Style'): 9, 357 | ( 358 | 'Safety', 359 | 'Capacity'): 1, 360 | ('Style', 361 | 'Capacity'): 0.14285714285714285}), 362 | 'computed': None}}, 363 | 'Cost': {'name': 'Cost', 'global_weight': 0.51, 'local_weight': 0.51, 364 | 'target_weights': None, 'elements': { 365 | 'global_weights': {'Price': 0.2489, 'Fuel': 0.1283, 366 | 'Resale': 0.0819, 'Maintenance': 0.0509}, 367 | 'local_weights': {'Price': 0.4881, 'Fuel': 0.2515, 'Resale': 0.1605, 368 | 'Maintenance': 0.0999}, 369 | 'consistency_ratio': 0.0164, 'random_index': 'Donegan & Dodd', 370 | 'count': 4, 'names': ['Price', 'Fuel', 'Maintenance', 'Resale']}, 371 | 'children': {'count': 4, 372 | 'names': ['Price', 'Fuel', 'Resale', 'Maintenance']}, 373 | 'comparisons': {'count': 6, 'input': {('Price', 'Fuel'): 2, 374 | ('Price', 'Maintenance'): 5, 375 | ('Price', 'Resale'): 3, 376 | ('Fuel', 'Maintenance'): 2, 377 | ('Fuel', 'Resale'): 2, ( 378 | 'Maintenance', 379 | 'Resale'): 0.5}, 380 | 'computed': None}}, 381 | 'Price': {'name': 'Price', 'global_weight': 0.2489, 'local_weight': 0.4881, 382 | 'target_weights': None, 'elements': { 383 | 'global_weights': {'Element': 0.091, 'Accord Sedan': 0.061, 384 | 'CR-V': 0.061, 'Odyssey': 0.023, 385 | 'Accord Hybrid': 0.006, 'Pilot': 0.006}, 386 | 'local_weights': {'Element': 0.366, 'Accord Sedan': 0.246, 387 | 'CR-V': 0.246, 'Odyssey': 0.093, 388 | 'Accord Hybrid': 0.025, 'Pilot': 0.025}, 389 | 'consistency_ratio': 0.072, 'random_index': 'Donegan & Dodd', 390 | 'count': 6, 391 | 'names': ['Accord Sedan', 'Accord Hybrid', 'Pilot', 'CR-V', 392 | 'Element', 'Odyssey']}, 'children': None, 393 | 'comparisons': {'count': 15, 394 | 'input': pytest.approx( 395 | {('Accord Sedan', 'Accord Hybrid'): 9, 396 | ('Accord Sedan', 'Pilot'): 9, 397 | ('Accord Sedan', 'CR-V'): 1, 398 | ('Accord Sedan', 'Element'): 0.5, 399 | ('Accord Sedan', 'Odyssey'): 5, 400 | ('Accord Hybrid', 'Pilot'): 1, ( 401 | 'Accord Hybrid', 402 | 'CR-V'): 0.1111111111111111, ( 403 | 'Accord Hybrid', 404 | 'Element'): 0.1111111111111111, ( 405 | 'Accord Hybrid', 406 | 'Odyssey'): 0.14285714285714285, 407 | ('Pilot', 'CR-V'): 0.1111111111111111, ( 408 | 'Pilot', 'Element'): 0.1111111111111111, 409 | ('Pilot', 410 | 'Odyssey'): 0.14285714285714285, 411 | ('CR-V', 'Element'): 0.5, 412 | ('CR-V', 'Odyssey'): 5, 413 | ('Element', 'Odyssey'): 6}), 414 | 'computed': None}}, 415 | 'Fuel': {'name': 'Fuel', 'global_weight': 0.1283, 'local_weight': 0.2515, 416 | 'target_weights': None, 'elements': { 417 | 'global_weights': {'Accord Hybrid': 0.027, 'Accord Sedan': 0.024, 418 | 'CR-V': 0.021, 'Odyssey': 0.02, 'Element': 0.019, 419 | 'Pilot': 0.017}, 420 | 'local_weights': {'Accord Hybrid': 0.211, 'Accord Sedan': 0.187, 421 | 'CR-V': 0.163, 'Odyssey': 0.157, 'Element': 0.151, 422 | 'Pilot': 0.132}, 'consistency_ratio': 0.0, 423 | 'random_index': 'Donegan & Dodd', 'count': 6, 424 | 'names': ['Accord Sedan', 'Accord Hybrid', 'Pilot', 'CR-V', 425 | 'Element', 'Odyssey']}, 'children': None, 426 | 'comparisons': {'count': 15, 'input': pytest.approx({ 427 | ('Accord Sedan', 'Accord Hybrid'): 0.8849557522123894, 428 | ('Accord Sedan', 'Pilot'): 1.41, 429 | ('Accord Sedan', 'CR-V'): 1.15, 430 | ('Accord Sedan', 'Element'): 1.24, 431 | ('Accord Sedan', 'Odyssey'): 1.19, 432 | ('Accord Hybrid', 'Pilot'): 1.59, 433 | ('Accord Hybrid', 'CR-V'): 1.3, 434 | ('Accord Hybrid', 'Element'): 1.4, 435 | ('Accord Hybrid', 'Odyssey'): 1.35, 436 | ('Pilot', 'CR-V'): 0.8130081300813008, 437 | ('Pilot', 'Element'): 0.8771929824561404, 438 | ('Pilot', 'Odyssey'): 0.8474576271186441, 439 | ('CR-V', 'Element'): 1.08, ('CR-V', 'Odyssey'): 1.04, 440 | ('Element', 'Odyssey'): 0.9615384615384615}), 441 | 'computed': None}}, 442 | 'Resale': {'name': 'Resale', 'global_weight': 0.0819, 443 | 'local_weight': 0.1605, 'target_weights': None, 'elements': { 444 | 'global_weights': {'CR-V': 0.034, 'Accord Sedan': 0.018, 445 | 'Element': 0.009, 'Odyssey': 0.009, 446 | 'Accord Hybrid': 0.008, 'Pilot': 0.005}, 447 | 'local_weights': {'CR-V': 0.416, 'Accord Sedan': 0.225, 448 | 'Element': 0.105, 'Odyssey': 0.105, 449 | 'Accord Hybrid': 0.095, 'Pilot': 0.055}, 450 | 'consistency_ratio': 0.005, 'random_index': 'Donegan & Dodd', 451 | 'count': 6, 452 | 'names': ['Accord Sedan', 'Accord Hybrid', 'Pilot', 'CR-V', 453 | 'Element', 'Odyssey']}, 'children': None, 454 | 'comparisons': {'count': 15, 455 | 'input': pytest.approx( 456 | {('Accord Sedan', 'Accord Hybrid'): 3, 457 | ('Accord Sedan', 'Pilot'): 4, 458 | ('Accord Sedan', 'CR-V'): 0.5, 459 | ('Accord Sedan', 'Element'): 2, 460 | ('Accord Sedan', 'Odyssey'): 2, 461 | ('Accord Hybrid', 'Pilot'): 2, 462 | ('Accord Hybrid', 'CR-V'): 0.2, 463 | ('Accord Hybrid', 'Element'): 1, 464 | ('Accord Hybrid', 'Odyssey'): 1, 465 | ('Pilot', 'CR-V'): 0.16666666666666666, 466 | ('Pilot', 'Element'): 0.5, 467 | ('Pilot', 'Odyssey'): 0.5, 468 | ('CR-V', 'Element'): 4, 469 | ('CR-V', 'Odyssey'): 4, 470 | ('Element', 'Odyssey'): 1}), 471 | 'computed': None}}, 472 | 'Maintenance': {'name': 'Maintenance', 'global_weight': 0.0509, 473 | 'local_weight': 0.0999, 'target_weights': None, 474 | 'elements': {'global_weights': {'Accord Sedan': 0.018, 475 | 'Accord Hybrid': 0.016, 476 | 'CR-V': 0.005, 477 | 'Element': 0.004, 478 | 'Pilot': 0.004, 479 | 'Odyssey': 0.003}, 480 | 'local_weights': {'Accord Sedan': 0.358, 481 | 'Accord Hybrid': 0.313, 482 | 'CR-V': 0.1, 483 | 'Element': 0.088, 484 | 'Pilot': 0.084, 485 | 'Odyssey': 0.057}, 486 | 'consistency_ratio': 0.023, 487 | 'random_index': 'Saaty', 'count': 6, 488 | 'names': ['Accord Sedan', 'Accord Hybrid', 489 | 'Pilot', 'CR-V', 'Element', 490 | 'Odyssey']}, 'children': None, 491 | 'comparisons': {'count': 15, 'input': { 492 | ('Accord Sedan', 'Accord Hybrid'): 1.5, 493 | ('Accord Sedan', 'Pilot'): 4, 494 | ('Accord Sedan', 'CR-V'): 4, 495 | ('Accord Sedan', 'Element'): 4, 496 | ('Accord Sedan', 'Odyssey'): 5, 497 | ('Accord Hybrid', 'Pilot'): 4, 498 | ('Accord Hybrid', 'CR-V'): 4, 499 | ('Accord Hybrid', 'Element'): 4, 500 | ('Accord Hybrid', 'Odyssey'): 5, ('Pilot', 'CR-V'): 1, 501 | ('Pilot', 'Element'): 1.2, ('Pilot', 'Odyssey'): 1, 502 | ('CR-V', 'Element'): 1, ('CR-V', 'Odyssey'): 3, 503 | ('Element', 'Odyssey'): 2}, 'computed': None}}, 504 | 'Safety': {'name': 'Safety', 'global_weight': 0.234, 'local_weight': 0.234, 505 | 'target_weights': None, 'elements': { 506 | 'global_weights': {'Odyssey': 0.102, 'Accord Sedan': 0.051, 507 | 'Accord Hybrid': 0.051, 'Pilot': 0.018, 508 | 'CR-V': 0.008, 'Element': 0.005}, 509 | 'local_weights': {'Odyssey': 0.434, 'Accord Sedan': 0.216, 510 | 'Accord Hybrid': 0.216, 'Pilot': 0.075, 511 | 'CR-V': 0.036, 'Element': 0.022}, 512 | 'consistency_ratio': 0.085, 'random_index': 'Donegan & Dodd', 513 | 'count': 6, 514 | 'names': ['Accord Sedan', 'Accord Hybrid', 'Pilot', 'CR-V', 515 | 'Element', 'Odyssey']}, 'children': None, 516 | 'comparisons': {'count': 15, 517 | 'input': pytest.approx( 518 | {('Accord Sedan', 'Accord Hybrid'): 1, 519 | ('Accord Sedan', 'Pilot'): 5, 520 | ('Accord Sedan', 'CR-V'): 7, 521 | ('Accord Sedan', 'Element'): 9, ( 522 | 'Accord Sedan', 523 | 'Odyssey'): 0.3333333333333333, 524 | ('Accord Hybrid', 'Pilot'): 5, 525 | ('Accord Hybrid', 'CR-V'): 7, 526 | ('Accord Hybrid', 'Element'): 9, ( 527 | 'Accord Hybrid', 528 | 'Odyssey'): 0.3333333333333333, 529 | ('Pilot', 'CR-V'): 2, 530 | ('Pilot', 'Element'): 9, 531 | ('Pilot', 'Odyssey'): 0.125, 532 | ('CR-V', 'Element'): 2, 533 | ('CR-V', 'Odyssey'): 0.125, ('Element', 534 | 'Odyssey'): 0.1111111111111111}), 535 | 'computed': None}}, 536 | 'Style': {'name': 'Style', 'global_weight': 0.041, 'local_weight': 0.041, 537 | 'target_weights': None, 'elements': { 538 | 'global_weights': {'Accord Sedan': 0.015, 'Accord Hybrid': 0.015, 539 | 'CR-V': 0.006, 'Odyssey': 0.003, 'Pilot': 0.002, 540 | 'Element': 0.001}, 541 | 'local_weights': {'Accord Sedan': 0.358, 'Accord Hybrid': 0.358, 542 | 'CR-V': 0.155, 'Odyssey': 0.068, 'Pilot': 0.039, 543 | 'Element': 0.023}, 'consistency_ratio': 0.107, 544 | 'random_index': 'Donegan & Dodd', 'count': 6, 545 | 'names': ['Accord Sedan', 'Accord Hybrid', 'Pilot', 'CR-V', 546 | 'Element', 'Odyssey']}, 'children': None, 547 | 'comparisons': {'count': 15, 548 | 'input': pytest.approx( 549 | {('Accord Sedan', 'Accord Hybrid'): 1, 550 | ('Accord Sedan', 'Pilot'): 7, 551 | ('Accord Sedan', 'CR-V'): 5, 552 | ('Accord Sedan', 'Element'): 9, 553 | ('Accord Sedan', 'Odyssey'): 6, 554 | ('Accord Hybrid', 'Pilot'): 7, 555 | ('Accord Hybrid', 'CR-V'): 5, 556 | ('Accord Hybrid', 'Element'): 9, 557 | ('Accord Hybrid', 'Odyssey'): 6, 558 | ('Pilot', 'CR-V'): 0.16666666666666666, 559 | ('Pilot', 'Element'): 3, ( 560 | 'Pilot', 'Odyssey'): 0.3333333333333333, 561 | ('CR-V', 'Element'): 7, 562 | ('CR-V', 'Odyssey'): 5, 563 | ('Element', 'Odyssey'): 0.2}), 564 | 'computed': None}}, 565 | 'Capacity': {'name': 'Capacity', 'global_weight': 0.215, 566 | 'local_weight': 0.215, 'target_weights': None, 'elements': { 567 | 'global_weights': {'Passenger': 0.1792, 'Cargo': 0.0358}, 568 | 'local_weights': {'Passenger': 0.8333, 'Cargo': 0.1667}, 569 | 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 570 | 'count': 2, 'names': ['Cargo', 'Passenger']}, 571 | 'children': {'count': 2, 'names': ['Passenger', 'Cargo']}, 572 | 'comparisons': {'count': 1, 573 | 'input': {('Cargo', 'Passenger'): 0.2}, 574 | 'computed': None}}, 575 | 'Passenger': {'name': 'Passenger', 'global_weight': 0.1792, 576 | 'local_weight': 0.8333, 'target_weights': None, 'elements': { 577 | 'global_weights': {'Pilot': 0.0489, 'Odyssey': 0.0489, 578 | 'Accord Sedan': 0.0244, 'Accord Hybrid': 0.0244, 579 | 'CR-V': 0.0244, 'Element': 0.0082}, 580 | 'local_weights': {'Pilot': 0.2727, 'Odyssey': 0.2727, 581 | 'Accord Sedan': 0.1364, 'Accord Hybrid': 0.1364, 582 | 'CR-V': 0.1364, 'Element': 0.0455}, 583 | 'consistency_ratio': 0.0, 'random_index': 'Donegan & Dodd', 584 | 'count': 6, 585 | 'names': ['Accord Sedan', 'Accord Hybrid', 'Pilot', 'CR-V', 586 | 'Element', 'Odyssey']}, 'children': None, 587 | 'comparisons': {'count': 15, 'input': pytest.approx({ 588 | ('Accord Sedan', 'Accord Hybrid'): 1, 589 | ('Accord Sedan', 'Pilot'): 0.5, 590 | ('Accord Sedan', 'CR-V'): 1, 591 | ('Accord Sedan', 'Element'): 3, 592 | ('Accord Sedan', 'Odyssey'): 0.5, 593 | ('Accord Hybrid', 'Pilot'): 0.5, 594 | ('Accord Hybrid', 'CR-V'): 1, 595 | ('Accord Hybrid', 'Element'): 3, 596 | ('Accord Hybrid', 'Odyssey'): 0.5, ('Pilot', 'CR-V'): 2, 597 | ('Pilot', 'Element'): 6, ('Pilot', 'Odyssey'): 1, 598 | ('CR-V', 'Element'): 3, ('CR-V', 'Odyssey'): 0.5, 599 | ('Element', 'Odyssey'): 0.16666666666666666}), 600 | 'computed': None}}, 601 | 'Cargo': {'name': 'Cargo', 'global_weight': 0.0358, 'local_weight': 0.1667, 602 | 'target_weights': None, 'elements': { 603 | 'global_weights': {'Odyssey': 0.0111, 'Pilot': 0.0061, 604 | 'CR-V': 0.0061, 'Element': 0.0061, 605 | 'Accord Sedan': 0.0032, 'Accord Hybrid': 0.0032}, 606 | 'local_weights': {'Odyssey': 0.3106, 'Pilot': 0.1702, 607 | 'CR-V': 0.1702, 'Element': 0.1702, 608 | 'Accord Sedan': 0.0894, 'Accord Hybrid': 0.0894}, 609 | 'consistency_ratio': 0.0023, 'random_index': 'Donegan & Dodd', 610 | 'count': 6, 611 | 'names': ['Accord Sedan', 'Accord Hybrid', 'Pilot', 'CR-V', 612 | 'Element', 'Odyssey']}, 'children': None, 613 | 'comparisons': {'count': 15, 614 | 'input': pytest.approx( 615 | {('Accord Sedan', 'Accord Hybrid'): 1, 616 | ('Accord Sedan', 'Pilot'): 0.5, 617 | ('Accord Sedan', 'CR-V'): 0.5, 618 | ('Accord Sedan', 'Element'): 0.5, ( 619 | 'Accord Sedan', 620 | 'Odyssey'): 0.3333333333333333, 621 | ('Accord Hybrid', 'Pilot'): 0.5, 622 | ('Accord Hybrid', 'CR-V'): 0.5, 623 | ('Accord Hybrid', 'Element'): 0.5, ( 624 | 'Accord Hybrid', 625 | 'Odyssey'): 0.3333333333333333, 626 | ('Pilot', 'CR-V'): 1, 627 | ('Pilot', 'Element'): 1, 628 | ('Pilot', 'Odyssey'): 0.5, 629 | ('CR-V', 'Element'): 1, 630 | ('CR-V', 'Odyssey'): 0.5, 631 | ('Element', 'Odyssey'): 0.5}), 632 | 'computed': None}}} 633 | --------------------------------------------------------------------------------