37. Optimizando código Python#

Como ya hemos mencionado Python es un lenguaje versátil pero poco eficiente en comparación a lenguajes de bajo nivel (C o Fortran)

Recordemos que Python es un lenguaje interpretado. Consideremos la operación

    z = x + y

Esta operación sencilla requiere de

  • inferir el tipo de \(x\) e \(y\) antes de sumarlos

  • escoger la función “suma” apropiada para el tipo de los argumentos

  • retornar el tipo correcto de z

El costo de las operaciones destacadas se llama overhead. El overhead es el costo extra de Python versus los lenguajes compilados

Existen varias maneras de reducir overhead y mejorar la eficiencia de un código escrito en Python. En lo que sige revisaremos las siguientes:

  • Conocer el lenguaje: Usar la sintaxis y estructuras de Python adecuadamente

  • Vectorización: Cómputo basado en arreglos con NumPy siempre en problemas que lo permitan

  • Conectar con lenguajes de bajo nivel: Utilizar Cython para crear interfaces de código C eficiente para Python

Antes de empezar revisarlas en detalle se presenta una métrica muy utilizada para comparar algoritmos de acuerdo a su tiempo de ejecución. El speedup es un número que mide el desempeño relativo de dos algoritmos, sistemas o rutinas, definido como

\[ S_{tiempo} = \frac{T_{referencia}}{T_{propuesto}} \]

donde \(T_{propuesto}\) y \(T_{referencia}\) son los tiempos de cómputo de nuestra rutina propuesta (optimizada) y de la rutina de referencia (original), respectivamente

Nota

Este speedup temporal indica cuantas veces más rápido es nuestra rutina propuesta con respecto a la referencia

37.1. Conocer el lenguaje para ganar eficiencia#

Python tiene una curva de aprendizaje suave en comparación a lenguajes de más bajo nivel, es decir que sabiendo muy poco de Python ya somos capaces de escribir toda clase de rutinas

Esto tiene un efecto secundario negativo en algunas personas y especialmente en aquellos que ya saben otros lenguajes

Error

Usar Python como si fuera C (u otro lenguaje)

Python ofrece una gran cantidad de funciones y módulos en su librería estándar que son sumamente eficientes

Consejo

Usar la sintáxis y las estructuras de datos de Python adecuadamente es el primer paso para escribir código eficiente. Busque en la documentación de Python las estructuras de datos más apropiadas para cada problema

Si necesita repasar sobre algoritmos se recomienda el siguiente material

A continuación veremos algunos consejos generales enfocados a Python

37.2. Evita usar for siempre que se pueda en favor de las funciones nativas#

Muchas veces podemos evitar usar for con la estructura de datos o función adecuada.

Para ejemplificar digamos que queremos sumar los valores absolutos de los elementos de una lista:

x = [x for x in range(100000)]

# Suma estilo C 
def suma_abs(data):
    resultado = 0
    for i in range(len(data)):
        if data[i] > 0:
            resultado += data[i]
        else:
            resultado -= data[i]
    return resultado

reference = %timeit -r5 -n3 -o suma_abs(x)
suma_abs(x)
14.5 ms ± 943 µs per loop (mean ± std. dev. of 5 runs, 3 loops each)
4999950000

Mejora 1: No es necesario usar un índice, podemos iterar directamente en los elementos

def suma_abs(data):
    resultado = 0
    for element in data:
        if element > 0:
            resultado += element
        else:
            resultado -= element
    return resultado

proposal = %timeit -r5 -n3 -o suma_abs(x)
suma_abs(x)
8.54 ms ± 749 µs per loop (mean ± std. dev. of 5 runs, 3 loops each)
4999950000

El speed up en este caso es:

reference.average/proposal.average
1.697372481705621

Mejora 2: Operar como una comprensión de lista y luego usar la función sum de Python

proposal = %timeit -r5 -n3 -o sum([x if x> 0 else -x for x in x])
sum([x if x> 0 else -x for x in x]), reference.average/proposal.average
7.09 ms ± 629 µs per loop (mean ± std. dev. of 5 runs, 3 loops each)
(4999950000, 2.0450631952204765)

Mejora 3: Usar las funciones sum, map y abs nativas de Python

proposal = %timeit -r5 -n3 -o sum(list(map(abs, x)))
sum(list(map(abs, x))), reference.average/proposal.average
4.16 ms ± 198 µs per loop (mean ± std. dev. of 5 runs, 3 loops each)
(4999950000, 3.4893538463698914)

37.3. No reinventar la rueda con las estructuras de datos#

Verifica si la estructura que necesitas está implementada en Python antes de programarla uno mismo. Como ejemplo digamos que queremos contar la cantidad de elementos de cada tipo en una lista. Podríamos escribir un contador como:

import random
x2 = [random.randint(0, 10) for k in range(10000)]

# Un contador de elementos
def miCounter(data):
    count = {}
    for element in data:
        if element not in count:
            count[element] = 1
        else:
            count[element] +=1
    return count

reference = %timeit -r7 -n1 -o miCounter(x2)
miCounter(x2)
1.92 ms ± 215 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)
{8: 877,
 5: 921,
 0: 928,
 3: 889,
 6: 919,
 7: 935,
 10: 879,
 9: 962,
 2: 948,
 4: 869,
 1: 873}

Sin embargo, la clase contador ya existe en collections, y es mucho más eficiente que la implementación manual anterior:

from collections import Counter
proposal = %timeit -r7 -n1 -o Counter(x2)
Counter(x2), reference.average/proposal.average
734 µs ± 110 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)
(Counter({9: 962,
          2: 948,
          7: 935,
          0: 928,
          5: 921,
          6: 919,
          3: 889,
          10: 879,
          8: 877,
          1: 873,
          4: 869}),
 2.6154451754086665)

37.4. Tener presente el overhead de las funciones#

Python tiene un overhead considerable cada vez que se llama una función. Se puede ganar desempeño haciendo inlining de funciones:

import numpy as np

def cuadradomasuno(element):
    return element*element + 1

reference = %timeit -r7 -n3 -o [cuadradomasuno(xi) for xi in x]
#Inlining: escribo la función textualmente en lugar de evaluarla
proposal = %timeit -r7 -n3 -o [xi*xi + 1 for xi in x] 

same_result = np.allclose([cuadradomasuno(xi) for xi in x], [xi*xi + 1 for xi in x])
speed_up = reference.average/proposal.average
same_result, speed_up
22.9 ms ± 1.02 ms per loop (mean ± std. dev. of 7 runs, 3 loops each)
14.2 ms ± 1.35 ms per loop (mean ± std. dev. of 7 runs, 3 loops each)
(True, 1.6171941472628382)

37.5. Usar variables locales dentro de los loops#

Si estamos obligados a usar for podemos ganar algo de rendimiento haciendo copias locales de atributos y funciones. Por ejemplo, digamos que queremos crear una lista con todos los elementos de otra lista que cumplen la condición

\[ \sin(x[i]) > 0 \]

que se implementa como sigue:

import math

# iterando sobre la lista
def sin_pos(data):
    resultado = []
    for element in data:
        if math.sin(element) > 0:
            resultado.append(element)
    return resultado

reference = %timeit -r5 -n3 -o sin_pos(x)
resultado1 = sin_pos(x)
22.9 ms ± 739 µs per loop (mean ± std. dev. of 5 runs, 3 loops each)

Si hacemos variables locales para el método append y la función sin:

# Mejora: variables locales
def sin_pos(data):
    resultado = []
    append = resultado.append
    sin = math.sin
    for element in data:
        if sin(element) > 0:
            append(element)
    return resultado

proposal = %timeit -r5 -n3 -o sin_pos(x)
resultado2 = sin_pos(x)

same_result = np.allclose(resultado1, resultado2)
speed_up = reference.average/proposal.average
same_result, speed_up
14.4 ms ± 1 ms per loop (mean ± std. dev. of 5 runs, 3 loops each)
(True, 1.5929659944837002)