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 permitanConectar 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
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
Bibliografía complementaria del curso: Effective Python
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
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)