Este artigo trata da principal ferramenta de organização de código em projetos de IA: a função. Vamos ver como defini-las, como o Python resolve o escopo das variáveis, quais tipos de parâmetro existem e quando a recursão é a estrutura certa para o problema.

A onipresença das funções em Machine Learning e Deep Learning não é coincidência. Elas aparecem no pré-processamento dos dados, na avaliação de modelos — acurácia, F1 e companhia —, no ciclo de treinamento com os clássicos train() e evaluate(), nas ativações de redes neurais como ReLU, sigmoid e softmax, e nas funções de perda como MSE e Cross-Entropy. Saber escrevê-las bem estruturadas, com parâmetros claros e responsabilidade única, é o que separa um script experimental de código que aguenta produção.

A definição usa a palavra-chave def, seguida do nome, dos parênteses com os parâmetros e de dois pontos, com o corpo indentado. Na forma mais enxuta, sem parâmetro nenhum:

def saudacao():
    print("Olá! Bem-vindo ao curso de IA.")

saudacao()

Saída:

Olá! Bem-vindo ao curso de IA.

Parâmetros tornam a função reutilizável, permitindo que o mesmo código atenda entradas diferentes:

def saudar_pessoa(nome):
    print(f"Olá, {nome}! Bem-vindo ao curso de IA.")

saudar_pessoa("Ana")
saudar_pessoa("Carlos")

Saída:

Olá, Ana! Bem-vindo ao curso de IA.
Olá, Carlos! Bem-vindo ao curso de IA.

Na maioria das vezes, porém, você quer que a função devolva um resultado em vez de imprimi-lo — é o papel do return:

def calcular_imc(peso, altura):
    imc = peso / (altura ** 2)
    return imc

resultado = calcular_imc(70, 1.75)
print(f"IMC: {resultado:.2f}")

Saída:

IMC: 22.86

Uma conveniência que o Python oferece e que economiza muito código: devolver vários valores de uma vez, empacotados como tupla e desempacotados na atribuição.

def estatisticas(numeros):
    media = sum(numeros) / len(numeros)
    minimo = min(numeros)
    maximo = max(numeros)
    return media, minimo, maximo

notas = [7.5, 8.0, 6.5, 9.0, 7.0]
media, minimo, maximo = estatisticas(notas)

print(f"Média: {media:.2f}")
print(f"Mínimo: {minimo}")
print(f"Máximo: {maximo}")

Saída:

Média: 7.60
Mínimo: 6.5
Máximo: 9.0

Os parâmetros têm mais variedade do que parece à primeira vista, e vale conhecer cada tipo. Os posicionais são os mais simples, e neles a ordem é tudo:

def subtrair(a, b):
    return a - b

print(subtrair(10, 3))  # 7
print(subtrair(3, 10))  # -7 — ordem importa!

Atribuir um valor padrão torna o parâmetro opcional na chamada. A única regra é que parâmetros com default venham depois dos que não têm:

def saudacao(nome, idioma="pt"):
    if idioma == "pt":
        return f"Olá, {nome}!"
    elif idioma == "en":
        return f"Hello, {nome}!"
    elif idioma == "es":
        return f"¡Hola, {nome}!"

print(saudacao("Ana"))           # usa o default "pt"
print(saudacao("Bob", "en"))
print(saudacao("Carlos", "es"))

Saída:

Olá, Ana!
Hello, Bob!
¡Hola, Carlos!

Esse recurso é a espinha dorsal das APIs de ML, onde hiperparâmetros ganham valores sensatos por padrão e você sobrescreve apenas o que interessa:

def treinar_modelo(dados, learning_rate=0.01, epocas=100, verbose=True):
    print(f"Treinando com lr={learning_rate}, épocas={epocas}")
    # lógica de treinamento aqui...

treinar_modelo(dados=None)                          # usa todos os defaults
treinar_modelo(dados=None, learning_rate=0.001)     # muda só o lr
treinar_modelo(dados=None, epocas=500, verbose=False)

Passar argumentos pelo nome, e não pela posição, deixa a chamada autoexplicativa e libera você da ordem original:

def criar_modelo(camadas, neuronios, ativacao):
    print(f"Modelo: {camadas} camadas, {neuronios} neurônios, ativação {ativacao}")

# Chamadas equivalentes
criar_modelo(3, 128, "relu")
criar_modelo(camadas=3, neuronios=128, ativacao="relu")
criar_modelo(ativacao="relu", camadas=3, neuronios=128)  # ordem não importa com nomes

Quando o número de argumentos é desconhecido de antemão, *args recolhe todos os posicionais numa tupla:

def somar_tudo(*numeros):
    print(f"Argumentos recebidos: {numeros}")
    return sum(numeros)

print(somar_tudo(1, 2, 3))
print(somar_tudo(10, 20, 30, 40, 50))

Saída:

Argumentos recebidos: (1, 2, 3)
6
Argumentos recebidos: (10, 20, 30, 40, 50)
150

Seu equivalente para argumentos nomeados é **kwargs, que os entrega como dicionário. É o mecanismo por trás das configurações flexíveis dos frameworks de ML:

def configurar_modelo(**kwargs):
    print("Configurações recebidas:")
    for chave, valor in kwargs.items():
        print(f"  {chave}: {valor}")

configurar_modelo(
    learning_rate=0.001,
    batch_size=32,
    optimizer="adam",
    dropout=0.5
)

Saída:

Configurações recebidas:
  learning_rate: 0.001
  batch_size: 32
  optimizer: adam
  dropout: 0.5

Todos podem coexistir na mesma assinatura, desde que respeitada a ordem: primeiro os posicionais, depois *args, então os que têm valor padrão e por último **kwargs.

def funcao_completa(obrigatorio, *args, opcional="padrão", **kwargs):
    print(f"Obrigatório: {obrigatorio}")
    print(f"Args extras: {args}")
    print(f"Opcional: {opcional}")
    print(f"Kwargs: {kwargs}")

funcao_completa("valor", 1, 2, 3, opcional="custom", chave="valor_extra")

Saída:

Obrigatório: valor
Args extras: (1, 2, 3)
Opcional: custom
Kwargs: {'chave': 'valor_extra'}

Definida a função, surge a questão de onde suas variáveis existem. Escopo é justamente isso — onde um nome pode ser acessado — e o Python resolve a busca pela regra LEGB, procurando na ordem Local, Enclosing, Global e Built-in.

No escopo local, variáveis criadas dentro da função só existem enquanto ela roda, e tentar alcançá-las de fora resulta em erro:

def calcular():
    resultado = 42  # variável local
    print(resultado)

calcular()
print(resultado)  # NameError: name 'resultado' is not defined

Já as variáveis criadas fora de qualquer função são globais e podem ser lidas de dentro delas sem cerimônia:

taxa = 0.1  # variável global

def calcular_imposto(valor):
    return valor * taxa  # lê a variável global

print(calcular_imposto(1000))  # 100.0

Ler é uma coisa, modificar é outra: para alterar uma global de dentro da função é preciso declará-la com global. Use com parcimônia, porque dependência de estado global é o tipo de coisa que dificulta testes e manutenção:

contador = 0

def incrementar():
    global contador
    contador += 1

incrementar()
incrementar()
incrementar()
print(contador)  # 3

O "E" do LEGB aparece quando uma função é definida dentro de outra: a interna enxerga o escopo da externa.

def externa():
    mensagem = "Olá do escopo externo"

    def interna():
        print(mensagem)  # acessa a variável da função externa

    interna()

externa()

Saída:

Olá do escopo externo

E, do mesmo modo que global libera a escrita no escopo global, nonlocal libera a escrita no escopo da função que envolve — padrão que permite construir contadores com estado próprio:

def contador_fabrica():
    contagem = 0

    def incrementar():
        nonlocal contagem
        contagem += 1
        return contagem

    return incrementar

contador = contador_fabrica()
print(contador())  # 1
print(contador())  # 2
print(contador())  # 3

Esse último exemplo revela algo importante: em Python, funções são objetos como quaisquer outros. Você pode atribuí-las a variáveis, passá-las como argumento e devolvê-las de outras funções. É exatamente por isso que os frameworks de ML conseguem receber funções de ativação e de perda como parâmetro.

Atribuir a uma variável é tão simples quanto omitir os parênteses — você guarda a função, não o resultado dela:

def quadrado(x):
    return x ** 2

operacao = quadrado
print(operacao(5))  # 25

Passá-la adiante como argumento abre o caminho para código genérico, em que o comportamento é decidido por quem chama:

def aplicar(funcao, valor):
    return funcao(valor)

def dobrar(x):
    return x * 2

def quadrado(x):
    return x ** 2

print(aplicar(dobrar, 5))    # 10
print(aplicar(quadrado, 5))  # 25

Levando a ideia para o terreno de redes neurais, é assim que uma mesma rotina aplica ativações diferentes ao mesmo conjunto de entradas:

import math

def relu(x):
    return max(0, x)

def sigmoid(x):
    return 1 / (1 + math.exp(-x))

def aplicar_ativacao(valores, funcao_ativacao):
    return [funcao_ativacao(v) for v in valores]

entradas = [-2.0, -1.0, 0.0, 1.0, 2.0]

print("ReLU:   ", aplicar_ativacao(entradas, relu))
print("Sigmoid:", [round(v, 4) for v in aplicar_ativacao(entradas, sigmoid)])

Saída:

ReLU:    [0, 0, 0, 1.0, 2.0]
Sigmoid: [0.1192, 0.2689, 0.5, 0.7311, 0.8808]

Quando a função é tão pequena que nomeá-la formalmente parece exagero, existe a lambda — uma função anônima de uma linha, escrita como lambda parametros: expressao:

# Sintaxe: lambda parametros: expressao

quadrado = lambda x: x ** 2
print(quadrado(5))  # 25

soma = lambda a, b: a + b
print(soma(3, 4))   # 7

Seu uso mais frequente é como argumento de sorted(), map() e filter(). Ordenando dicionários por um campo, por exemplo:

alunos = [
    {"nome": "Ana", "nota": 8.5},
    {"nome": "Bruno", "nota": 6.0},
    {"nome": "Carla", "nota": 9.2},
]

# Ordenar por nota usando lambda
alunos_ordenados = sorted(alunos, key=lambda a: a["nota"], reverse=True)
for aluno in alunos_ordenados:
    print(f"{aluno['nome']}: {aluno['nota']}")

Saída:

Carla: 9.2
Ana: 8.5
Bruno: 6.0

Com map() você transforma cada elemento; com filter(), seleciona os que interessam:

numeros = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

# map: aplica uma função a cada elemento
dobrados = list(map(lambda x: x * 2, numeros))
print(dobrados)

# filter: filtra elementos pela condição
pares = list(filter(lambda x: x % 2 == 0, numeros))
print(pares)

Saída:

[2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
[2, 4, 6, 8, 10]

Funções que outras pessoas vão usar merecem documentação, e o lugar dela é a docstring: uma string logo abaixo da assinatura, acessível via help() e lida por ferramentas de documentação automática.

def calcular_acuracia(predicoes, rotulos_reais):
    """
    Calcula a acurácia de um modelo de classificação.

    Parâmetros:
        predicoes (list): Lista com as predições do modelo.
        rotulos_reais (list): Lista com os rótulos verdadeiros.

    Retorna:
        float: Acurácia entre 0.0 e 1.0.

    Exemplo:
        >>> calcular_acuracia([1, 0, 1, 1], [1, 0, 0, 1])
        0.75
    """
    if len(predicoes) != len(rotulos_reais):
        raise ValueError("As listas devem ter o mesmo tamanho.")

    corretos = sum(p == r for p, r in zip(predicoes, rotulos_reais))
    return corretos / len(rotulos_reais)

help(calcular_acuracia)
print(calcular_acuracia([1, 0, 1, 1], [1, 0, 0, 1]))

Saída:

0.75

Há uma última capacidade das funções que merece atenção: elas podem chamar a si mesmas. Recursão é a ferramenta natural para problemas de estrutura recursiva — árvores, dados hierárquicos, algoritmos como busca binária e merge sort. Toda função recursiva precisa de duas peças: o caso base, que é a condição de parada e sem o qual você tem recursão infinita, e o caso recursivo, que chama a própria função sobre um problema menor.

def contagem_regressiva(n):
    if n <= 0:          # caso base
        print("Lançar!")
    else:               # caso recursivo
        print(n)
        contagem_regressiva(n - 1)

contagem_regressiva(5)

Saída:

5
4
3
2
1
Lançar!

O fatorial é o exemplo canônico, porque sua própria definição matemática já é recursiva:

def fatorial(n):
    """Calcula n! recursivamente."""
    if n == 0 or n == 1:  # caso base
        return 1
    return n * fatorial(n - 1)  # caso recursivo

print(fatorial(5))   # 120
print(fatorial(10))  # 3628800

Acompanhar a cadeia de chamadas ajuda a enxergar o que acontece na memória:

fatorial(5)
  = 5 * fatorial(4)
  = 5 * 4 * fatorial(3)
  = 5 * 4 * 3 * fatorial(2)
  = 5 * 4 * 3 * 2 * fatorial(1)
  = 5 * 4 * 3 * 2 * 1
  = 120

Fibonacci segue o mesmo princípio, com dois casos base em vez de um:

def fibonacci(n):
    """Retorna o n-ésimo número de Fibonacci."""
    if n <= 1:           # casos base: fib(0) = 0, fib(1) = 1
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

for i in range(10):
    print(f"fib({i}) = {fibonacci(i)}")

Saída:

fib(0) = 0
fib(1) = 1
fib(2) = 1
fib(3) = 2
fib(4) = 3
fib(5) = 5
fib(6) = 8
fib(7) = 13
fib(8) = 21
fib(9) = 34

A elegância cobra seu preço, no entanto. O Python limita a profundidade da recursão a mil chamadas por padrão, e a versão ingênua do Fibonacci recalcula os mesmos valores incontáveis vezes. Para problemas grandes, o caminho é a solução iterativa ou a memoização, que guarda os resultados já computados:

import sys
print(sys.getrecursionlimit())  # 1000

# Fibonacci com memoização (muito mais eficiente)
def fibonacci_memo(n, memo={}):
    if n in memo:
        return memo[n]
    if n <= 1:
        return n
    memo[n] = fibonacci_memo(n - 1, memo) + fibonacci_memo(n - 2, memo)
    return memo[n]

print(fibonacci_memo(50))  # 12586269025 — rápido!

Fechando o repertório, o Python moderno encoraja anotar os tipos esperados de parâmetros e retornos. As type hints não são obrigatórias nem verificadas em tempo de execução, mas tornam a assinatura autoexplicativa e habilitam análise estática com ferramentas como o mypy:

def calcular_media(valores: list[float]) -> float:
    """Calcula a média de uma lista de números."""
    return sum(valores) / len(valores)

def normalizar(valor: float, minimo: float, maximo: float) -> float:
    """Normaliza um valor entre 0 e 1."""
    return (valor - minimo) / (maximo - minimo)

def classificar(probabilidade: float, limiar: float = 0.5) -> str:
    """Classifica uma probabilidade em positivo ou negativo."""
    return "positivo" if probabilidade >= limiar else "negativo"

Para ver tudo isso operando junto, vale montar um mini pipeline de pré-processamento — com funções pequenas, de responsabilidade única, documentadas e anotadas, orquestradas por uma função de nível mais alto. É bem próximo do que você encontra em projetos reais:

def carregar_dados() -> list[dict]:
    """Simula o carregamento de um dataset."""
    return [
        {"id": 1, "texto": "  O produto é ótimo!  ", "nota": 5},
        {"id": 2, "texto": "Péssimo, não recomendo.", "nota": 1},
        {"id": 3, "texto": None, "nota": 3},
        {"id": 4, "texto": "Bom custo benefício", "nota": 4},
        {"id": 5, "texto": "  ", "nota": 2},
    ]

def limpar_texto(texto: str | None) -> str | None:
    """Remove espaços extras e retorna None para textos vazios."""
    if texto is None:
        return None
    texto = texto.strip()
    return texto if texto else None

def filtrar_validos(dados: list[dict]) -> list[dict]:
    """Remove registros com texto ausente ou vazio."""
    return [d for d in dados if limpar_texto(d["texto"]) is not None]

def normalizar_notas(dados: list[dict], minimo: float = 1, maximo: float = 5) -> list[dict]:
    """Normaliza as notas para o intervalo [0, 1]."""
    for registro in dados:
        registro["nota_normalizada"] = (registro["nota"] - minimo) / (maximo - minimo)
    return dados

def preprocessar(dados: list[dict]) -> list[dict]:
    """Executa o pipeline completo de pré-processamento."""
    dados = filtrar_validos(dados)
    for d in dados:
        d["texto"] = limpar_texto(d["texto"])
    dados = normalizar_notas(dados)
    return dados

# Executando o pipeline
dados_brutos = carregar_dados()
dados_processados = preprocessar(dados_brutos)

print(f"Registros originais: {len(dados_brutos)}")
print(f"Registros válidos: {len(dados_processados)}\n")

for registro in dados_processados:
    print(f"ID {registro['id']}: '{registro['texto']}'")
    print(f"  Nota: {registro['nota']} → Normalizada: {registro['nota_normalizada']:.2f}")

Saída:

Registros originais: 5
Registros válidos: 3

ID 1: 'O produto é ótimo!'
  Nota: 5 → Normalizada: 1.00
ID 2: 'Péssimo, não recomendo.'
  Nota: 1 → Normalizada: 0.00
ID 4: 'Bom custo benefício'
  Nota: 4 → Normalizada: 0.75

Em resumo: funções nascem de def nome(parametros): e devolvem valores com return, inclusive vários de uma vez na forma de tupla. Os parâmetros podem ser posicionais, ter valor padrão, recolher extras com *args na forma de tupla ou com **kwargs na forma de dicionário. O escopo obedece à regra LEGB, com global e nonlocal liberando escrita nos níveis externos. Funções são objetos de primeira classe e circulam como argumento; lambdas resolvem os casos de uma linha; docstrings documentam e alimentam o help(); recursão exige caso base e atenção ao limite de profundidade; e type hints deixam a assinatura legível para pessoas e ferramentas.


Os seis exercícios abaixo passam por parâmetros, escopo, lambdas e recursão. Tente resolver cada um antes de abrir a resposta comentada.

  1. Escreva uma função chamada celsius_para_fahrenheit que receba uma temperatura em Celsius e retorne o equivalente em Fahrenheit. A fórmula é: F = (C × 9/5) + 32. Em seguida, use map() com uma lambda para converter a lista abaixo inteira de uma vez.

    temperaturas_celsius = [0, 20, 37, 100, -10]
    

    ✓ Resposta:
    def celsius_para_fahrenheit(celsius: float) -> float:
        """Converte temperatura de Celsius para Fahrenheit."""
        return (celsius * 9/5) + 32
    
    temperaturas_celsius = [0, 20, 37, 100, -10]
    temperaturas_fahrenheit = list(map(lambda c: celsius_para_fahrenheit(c), temperaturas_celsius))
    print(temperaturas_fahrenheit)
    

    Saída:

    [32.0, 68.0, 98.6, 212.0, 14.0]
    

    Ou de forma mais concisa, passando a função diretamente sem lambda:

    temperaturas_fahrenheit = list(map(celsius_para_fahrenheit, temperaturas_celsius))
    
  2. Escreva uma função resumo_lista que receba qualquer quantidade de números via *args e retorne um dicionário com as chaves "quantidade", "soma", "media", "minimo" e "maximo". Se nenhum número for passado, retorne None.

    ✓ Resposta:
    def resumo_lista(*args):
        """Retorna estatísticas básicas de uma lista de números."""
        if not args:
            return None
    
        return {
            "quantidade": len(args),
            "soma": sum(args),
            "media": sum(args) / len(args),
            "minimo": min(args),
            "maximo": max(args)
        }
    
    print(resumo_lista(3, 7, 2, 9, 4))
    print(resumo_lista())
    

    Saída:

    {'quantidade': 5, 'soma': 25, 'media': 5.0, 'minimo': 2, 'maximo': 9}
    None
    
  3. Explique com suas palavras o que é escopo em Python e o que acontece no código abaixo. O código funciona? Por quê?

    x = 10
    
    def dobrar():
        return x * 2
    
    def incrementar():
        x += 1
        return x
    
    print(dobrar())
    print(incrementar())
    

    ✓ Resposta:

    O código não funciona completamente. dobrar() funciona porque apenas lê a variável global x, o que é permitido. Mas incrementar() falha com UnboundLocalError porque tenta modificar x com x += 1, que é equivalente a x = x + 1. Ao fazer isso, Python entende que x é uma variável local da função, mas então tenta ler x antes de atribuí-la — o que causa o erro.

    Para corrigir incrementar(), precisaria declarar global x:

    x = 10
    
    def dobrar():
        return x * 2
    
    def incrementar():
        global x
        x += 1
        return x
    
    print(dobrar())       # 20
    print(incrementar())  # 11
    
  4. Escreva uma função recursiva soma_digitos(n) que receba um inteiro positivo e retorne a soma de seus dígitos. Por exemplo, soma_digitos(1234) deve retornar 10 (1+2+3+4).

    ✓ Resposta:
    def soma_digitos(n: int) -> int:
        """Retorna a soma dos dígitos de um inteiro positivo."""
        if n < 10:          # caso base: número de um dígito
            return n
        return (n % 10) + soma_digitos(n // 10)  # último dígito + resto
    
    print(soma_digitos(1234))   # 10
    print(soma_digitos(9999))   # 36
    print(soma_digitos(7))      # 7
    

    A lógica: n % 10 pega o último dígito (1234 % 10 = 4), n // 10 remove o último dígito (1234 // 10 = 123). A recursão continua até restar um único dígito.

  5. Reescreva a função abaixo de três formas: usando def normal, usando lambda, e usando list comprehension com filter.

    # Original
    def apenas_positivos(numeros):
        resultado = []
        for n in numeros:
            if n > 0:
                resultado.append(n)
        return resultado
    

    ✓ Resposta:
    numeros = [-3, 5, -1, 8, 0, 2, -7, 4]
    
    # Versão 1: def normal (original, já dada)
    def apenas_positivos_v1(numeros):
        resultado = []
        for n in numeros:
            if n > 0:
                resultado.append(n)
        return resultado
    
    # Versão 2: lambda com filter
    apenas_positivos_v2 = lambda nums: list(filter(lambda n: n > 0, nums))
    
    # Versão 3: list comprehension
    def apenas_positivos_v3(numeros):
        return [n for n in numeros if n > 0]
    
    print(apenas_positivos_v1(numeros))  # [5, 8, 2, 4]
    print(apenas_positivos_v2(numeros))  # [5, 8, 2, 4]
    print(apenas_positivos_v3(numeros))  # [5, 8, 2, 4]
    

    A versão com list comprehension é considerada a mais Pythonica das três.

  6. Explique a diferença entre *args e **kwargs. Quando você usaria cada um? Dê um exemplo de situação em projetos de ML onde cada um seria útil.

    ✓ Resposta:

    *args captura qualquer número de argumentos posicionais como uma tupla. Use quando você quer que a função aceite quantidades variáveis de valores sem nome.

    **kwargs captura qualquer número de argumentos nomeados como um dicionário. Use quando você quer que a função aceite configurações ou opções identificadas por nome.

    Exemplo em ML com *args: uma função que calcula a média de múltiplos scores de validação cruzada:

    def media_scores(*scores):
        return sum(scores) / len(scores)
    
    print(media_scores(0.82, 0.85, 0.79, 0.88))  # 0.835
    

    Exemplo em ML com **kwargs: uma função que cria e registra hiperparâmetros de um experimento:

    def registrar_experimento(nome_modelo, **hiperparametros):
        print(f"Modelo: {nome_modelo}")
        for param, valor in hiperparametros.items():
            print(f"  {param}: {valor}")
    
    registrar_experimento(
        "RandomForest",
        n_estimators=100,
        max_depth=5,
        min_samples_split=2,
        random_state=42
    )
    

Para aprofundar: