Как сделать python для циклов быстрее

У меня есть список словарей, например:

[{'user': '123456', 'db': 'db1', 'size': '8628'}
{'user': '123456', 'db': 'db1', 'size': '7168'}
{'user': '123456', 'db': 'db1', 'size': '38160'}
{'user': '222345', 'db': 'db3', 'size': '8628'}
{'user': '222345', 'db': 'db3', 'size': '8628'}
{'user': '222345', 'db': 'db5', 'size': '840'}
{'user': '34521', 'db': 'db6', 'size': '12288'}
{'user': '34521', 'db': 'db6', 'size': '476'}
{'user': '2345156', 'db': 'db7', 'size': '5120'}.....]

Этот список содержит миллионы записей. Каждый пользователь может быть найден в нескольких БД, каждый пользователь может иметь несколько целых в одной БД. Я хочу подвести итог, сколько занимает каждый пользователь на каждый db. Я не хочу использовать панд. На данный момент делаю так:

  • Я создаю 2 списка уникальных пользователей и уникальных баз данных
  • Используйте эти списки, чтобы перебирать большой список и суммировать, где пользователь и БД совпадают.
result = []
for user in unique_users:
    for db in unique_dbs:
        total_size = 0
        for i in big_list:
            if (i['user'] == user and i['db'] == db):
                total_size += float(i['size'])
        if (total_size) > 0:
            row = {}
            row['user'] = user
            row['db'] = db
            row['size'] = total_size
            result.append(row)

Проблема в том, что этот тройной цикл for превращается в нечто очень большое (сотни миллиардов итераций), которому требуется целая вечность, чтобы подвести итог. Если big_list маленький, это работает очень хорошо.

Как мне подойти к этому, чтобы сделать его быстрым и простым? Большое спасибо!

Почему бы просто не создать карту пользователя для списка баз данных и их соответствующего размера?

rdas 11.10.2022 10:39

как бы я это сделал?

Ioan Fulgeanu 11.10.2022 10:42

Почему вы не хотите использовать панд? Это было бы банально...

Nick 11.10.2022 12:51

Отсортированы ли данные по пользователю и БД?

Nick 11.10.2022 12:59

«Я хочу подвести итог, сколько места занимает каждый пользователь на каждую базу данных». Я не совсем понимаю. Для показанного здесь ввода, каким должен быть вывод?

Karl Knechtel 12.10.2022 07:10
Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
3
5
167
4
Перейти к ответу Данный вопрос помечен как решенный

Ответы 4

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

user_to_db_to_size = {}
for entry in unique_users:
    user = entry['user']
    db = entry['db']
    size = int(entry['size'])
    if user not in user_to_db_to_size:
        user_to_db_to_size[user] = {}
    if db not in user_to_db_to_size[user]:
        user_to_db_to_size[user][db] = 0
    user_to_db_to_size[user][db] += size

print(user_to_db_to_size)

Для ваших образцов данных он производит:

{'123456': {'db1': 53956}, '222345': {'db3': 17256, 'db5': 840}, '34521': {'db6': 12764}, '2345156': {'db7': 5120}}

И теперь вы можете получить доступ к общему размеру на пользователя/БД с помощью:

print(user_to_db_to_size['123456']['db1'])  # 53956

Просто примечание: кеширование поиска по базовому словарю даст вам 5%-е улучшение скорости. Я включил некоторые тесты ниже.

John M. 15.10.2022 18:49
Ответ принят как подходящий

В текущем подходе есть две основные проблемы: неэффективный алгоритм и неэффективная структура данных.

Во-первых, используемый алгоритм явно неэффективен, так как он много раз выполняет итерацию по большому списку. Нет необходимости перебирать весь список, чтобы отфильтровать уникального пользователя и базу данных. Вы можете выполнить итерацию по большому списку один раз и агрегировать данные с помощью словаря. Ключ целевого словаря — это просто кортеж (user, db). Значение словаря total_size. Вот непроверенный пример:

# Aggregation part
# Note: a default dict can be used instead to make the code possibly simpler
aggregate_dict = dict()
for i in big_list:
    key = (i['user'], i['db'])
    value = float(i['size'])
    if key in aggregate_dict:
        aggregate_dict[key] += value
    else:
        aggregate_dict[key] = value

# Fast creation of `result`
result = []
for user in unique_users:
    for db in unique_dbs:
        total_size = aggregate_dict.get((user, key))
        if total_size is not None and total_size > 0:
            result.append({'user': user, 'db': db, 'size': total_size})

Другая проблема — неэффективная структура данных: для каждой строки ключи реплицируются, а вместо них могут использоваться кортежи. На самом деле лучшая структура данных — хранить словарь (column, items) ключей и значений, где items — это список элементов для целевого столбца. Этот способ хранения данных называется фреймом данных. Это примерно то, что Pandas использует внутри (за исключением того, что это массив Numpy, который даже лучше, поскольку он более компактен и, как правило, более эффективен, чем список для большинства операций). Использование этой структуры данных как для ввода, так и для вывода должно привести к значительному ускорению (в сочетании с Numpy) и меньшему объему памяти.

Обратите внимание, что создание окончательных результатов в виде понимания списка примерно на 8% быстрее, поэтому [{'user': user, 'db': db, 'size': total_size} for user in unique_users for db in unique_dbs if (total_size := aggregate_dict.get((user, key)))]. Обратите внимание, что если предположить, что размеры положительны, я считаю, что if total_size is not None and total_size > 0 можно упростить до if total_size, поскольку как общий размер None, так и ноль будут оцениваться как false. Я включу тестовый код ниже для справки. Тайминги немного неустойчивы, но понимание списка постоянно быстрее.

John M. 15.10.2022 17:34

@Джон М. Действительно для понимания списка. Для размеров они, безусловно, положительны, но могут быть и не строго положительными (т. е., возможно, нулевыми). Спасибо, что указали на это и сделали тест.

Jérôme Richard 15.10.2022 17:44

Пожалуйста. Хотя ты проделал всю тяжелую работу. Я полагаю, что Null все равно будет оцениваться как false.

John M. 15.10.2022 17:45

На самом деле, я думал, что 8% — это неправильно. Ключ поиска в aggregate_dict.get((user, key)) должен быть aggregate_dict.get((user, db)). Ошибка означала, что результирующий список был пуст, поскольку он фактически искал кортеж (user, (key, value)). Замена на db ускоряет понимание списка на 38%.

John M. 15.10.2022 18:06

Если вы используете Counter и создаете кортежи пар значений (user, db) в качестве ключей, то:

from collections import Counter

data = [{'user': '123456', 'db': 'db1', 'size': '8628'},
        {'user': '123456', 'db': 'db1', 'size': '7168'},
        {'user': '123456', 'db': 'db1', 'size': '38160'},
        {'user': '222345', 'db': 'db3', 'size': '8628'},
        {'user': '222345', 'db': 'db3', 'size': '8628'},
        {'user': '222345', 'db': 'db5', 'size': '840'},
        {'user': '34521', 'db': 'db6', 'size': '12288'},
        {'user': '34521', 'db': 'db6', 'size': '476'},
        {'user': '2345156', 'db': 'db7', 'size': '5120'}]

print(sum((Counter({(d['user'], d['db']): int(d['size'])}) for d in data), start=Counter()))

Counter({('123456', 'db1'): 53956, ('222345', 'db3'): 17256, ('34521', 'db6'): 12764, ('2345156', 'db7'): 5120, ('222345', 'db5'): 840})

Таким образом, глядя на три предложенных решения этой проблемы, подход от rdas, вероятно, является победителем. С парой модификаций оно на 57 % быстрее, чем решение для Жерома, и в 180 раз быстрее, чем исходный код. Решение Сергея было примерно в 350 раз медленнее на значительно урезанном подмножестве результатов (1000 записей); он также, казалось, очень плохо масштабировался, и у меня не было времени ждать результатов от полного набора данных.

Тайминги следующие:

Метод Время Родственник Оригинал 102.83720700000413 180.4580695623077 Жером 0,8722491000080481 1.5306171118096032 Жером (обновлено) 0,9000219000154175 1,5793526426731528 рдас 0,6866130999987945 1.2048642527015463 рдас (обновлено) 0,6580462999991141 1.1547354157572032 rdas с defaultdict 0,5698675999883562 1,0

В ответе Жерома окончательный расчет результатов можно улучшить, заменив окончательную конструкцию списка на понимание списка. Глядя на изменение понимания изолированного списка, код обновления работает примерно на 38% быстрее. Стоит отметить, что это не является основным фактором общего времени.

[{'user': user, 'db': db, 'size': total_size} for user in unique_users for db in unique_dbs if (total_size := aggregate_dict.get((user, db)))]

сравнение до (Метод1) и после (Метод2) за несколько итераций дает:

Method1: 6.447344800049905
Method2: 4.851344000024255
Method1: 6.546811900043394
Method2: 4.816081999975722
Method1: 6.863985500007402
Method2: 4.755402299982961
Method1: 6.8024070000392385
Method2: 4.738170499971602
Method1: 6.524162200046703
Method2: 4.755566900013946

Первое обновление ответа rdas заключалось в том, чтобы избежать поиска в нескольких словарях путем кэширования поиска первого словаря:

if not (user_dict := user_to_db_to_size.get(user)):
    user_to_db_to_size[user] = { db: size }
elif not db in user_dict:
    user_dict[db] = size
else:
    user_dict[db] += size

впоследствии это было снова улучшено путем замены на collections.defaultdict, что устраняет дополнительные дорогостоящие поиски в словаре:

user_to_db_to_size = defaultdict(lambda: defaultdict(int))
for entry in big_list:
    user = entry['user']
    db = entry['db']
    size = int(entry['size'])
    user_to_db_to_size[user][db] += size
return user_to_db_to_size

Это код, используемый для запуска тестов:

import random
import timeit
from collections import defaultdict

random.seed(1)

test_iterations = 100
big_list = [{'user': random.randint(0, 100), 'db': f'db{random.randint(1, 10)}', 'size': f'{random.randint(100, 90000)}' } for i in range(10000)]
unique_users = { i['user'] for i in big_list }
unique_dbs = { i['db'] for i in big_list }

def method0():
    result = []
    for user in unique_users:
        for db in unique_dbs:
            total_size = 0
            for i in big_list:
                if (i['user'] == user and i['db'] == db):
                    total_size += float(i['size'])
            if (total_size) > 0:
                row = {}
                row['user'] = user
                row['db'] = db
                row['size'] = total_size
                result.append(row)
    return result

def method1():
    aggregate_dict = dict()
    for i in big_list:
        key = (i['user'], i['db'])
        value = float(i['size'])
        if key in aggregate_dict:
            aggregate_dict[key] += value
        else:
            aggregate_dict[key] = value
    result = []
    for user in unique_users:
        for db in unique_dbs:
            total_size = aggregate_dict.get((user, db))
            if total_size is not None and total_size > 0:
                result.append({'user': user, 'db': db, 'size': total_size})
    return result

def method2():
    aggregate_dict = dict()
    for i in big_list:
        key = (i['user'], i['db'])
        value = float(i['size'])
        if key in aggregate_dict:
            aggregate_dict[key] += value
        else:
            aggregate_dict[key] = value
    return [{'user': user, 'db': db, 'size': total_size} for user in unique_users for db in unique_dbs if (total_size := aggregate_dict.get((user, db)))]

def method3():
    user_to_db_to_size = {}
    for entry in big_list:
        user = entry['user']
        db = entry['db']
        size = int(entry['size'])
        if user not in user_to_db_to_size:
            user_to_db_to_size[user] = {}
        if db not in user_to_db_to_size[user]:
            user_to_db_to_size[user][db] = 0
        user_to_db_to_size[user][db] += size
    return user_to_db_to_size

def method4():
    user_to_db_to_size = {}
    for entry in big_list:
        user = entry['user']
        db = entry['db']
        size = int(entry['size'])
        if not (user_dict := user_to_db_to_size.get(user)):
            user_to_db_to_size[user] = { db: size }
        elif not db in user_dict:
            user_dict[db] = size
        else:
            user_dict[db] += size
    return user_to_db_to_size

def method5():
    user_to_db_to_size = defaultdict(lambda: defaultdict(int))
    for entry in big_list:
        user = entry['user']
        db = entry['db']
        size = int(entry['size'])
        user_to_db_to_size[user][db] += size
    return user_to_db_to_size

assert (m0 := method0()) == method1()
print('Method1 OK')
assert m0 == method2()
print('Method2 OK')
assert all(v['size'] == method3()[v['user']][v['db']] for v in m0)
print('Method3 OK')
assert all(v['size'] == method4()[v['user']][v['db']] for v in m0)
print('Method4 OK')
assert all(v['size'] == method5()[v['user']][v['db']] for v in m0)
print('Method5 OK')

t0 = timeit.timeit(method0, number=test_iterations)
t1 = timeit.timeit(method1, number=test_iterations)
t2 = timeit.timeit(method2, number=test_iterations)
t3 = timeit.timeit(method3, number=test_iterations)
t4 = timeit.timeit(method4, number=test_iterations)
t5 = timeit.timeit(method5, number=test_iterations)
tmin = min((t0, t1, t2, t3, t4, t5))

print(f'| Method           | Time | Relative      |')
print(f'|------------------|----------------------|')
print(f'| Original         | {t0} | {t0 / tmin}   |')
print(f'| Jérôme           | {t1} | {t1 / tmin}   |')
print(f'| Jérôme (updated) | {t2} | {t2 / tmin}   |')
print(f'| rdas             | {t3} | {t3 / tmin}   |')
print(f'| rdas (updated)   | {t4} | {t4 / tmin}   |')
print(f'| defaultdict      | {t5} | {t5 / tmin}   |')

приводит к:

Method1 OK
Method2 OK
Method3 OK
Method4 OK
Method4 OK
| Method           | Time | Relative      |
|------------------|----------------------|
| Original         | 102.83720700000413 | 180.4580695623077   |
| Jérôme           | 0.8722491000080481 | 1.5306171118096032   |
| Jérôme (updated) | 0.9000219000154175 | 1.5793526426731528   |
| rdas             | 0.6866130999987945 | 1.2048642527015463   |
| rdas (updated)   | 0.6580462999991141 | 1.1547354157572032   |
| defaultdict      | 0.5698675999883562 | 1.0   |

Примечание. Метод Сергея примерно эквивалентен:

def c():
    tmp = Counter()
    for d in big_list:
        tmp = tmp + Counter(d)
    return tmp

так эффективно создается новый словарь с каждой итерацией в цикле. Для каждой итерации в цикле создаваемый словарь будет больше. Я подозреваю, что это масштабируется как N ** 2 или хуже.

Другие вопросы по теме