Well, @MariosOreo beat me to it, but here’s my rewrite of your code anyway:
import torch
import torch.nn as nn
import matplotlib.pyplot as plt

n = 70
x = torch.linspace(0, 10, steps=n)
k = 2.5

y = k*x + 5*torch.rand(n)
a = torch.tensor([-1.0, 0.0], requires_grad=True)

def y_hat():
    return a[0] * x + a[1]

lr = 0.01
for t in range(10): 
    loss = (y_hat() - y).pow(2.0).mean()
    loss.backward()
    a.data -= lr * a.grad.data
    print(f"{loss.item():7.3f} {a.data} {a.grad.data}") 
    a.grad.zero_()

plt.scatter(x, y)
plt.scatter(x, y_hat().detach().numpy())
plt.show()

You were zeroing the gradient for a before updating it.
The circularity error in calling backward() was due to the gradient of a being involved in the update. Using a.data instead avoids that.
There is no need to use the nn.Parameter wrapper. That allows the registration of parameter Tensors with a Module, which you are not using here.
As @MariosOreo mentioned, the gradients of Tensors don’t exist until backward() is called.
This happens to everyone I think when they first try PyTorch because most of the examples show the gradient being zeroed at the beginning of the optimization loop. That’s why I zero them at the end of the loop. It makes the code a bit cleaner to not test for the existence of a.grad on every iteration.
Your constant k doesn’t need to be explicitly a Tensor. PyTorch will broadcast it when you use it.
I took the liberty of adding an extra coefficient to a for another example.