The Story That Explains TensorFlow
A restaurant kitchen works differently. The chef first writes the full recipe as a plan. Then the whole team runs that plan again and again, very fast, for hundreds of plates.
TensorFlow can work in both ways. The home-cook style is called eager mode. The restaurant style is called graph mode. You start with eager mode. You switch to graph mode when you need speed.
TensorFlow is a free, open-source library from Google for maths on large arrays of numbers. Its main use is machine learning and deep learning. It can run the same code on a CPU, a GPU, or a TPU. It can also compute gradients for you automatically. That is the key thing a neural network needs to learn.
A tensor is a box of numbers with a fixed shape, like a list, a table, or a cube. Flow means these tensors move through a chain of operations. So TensorFlow = tensors flowing through maths operations.
Three Building Blocks
This is one dense layer: y = relu(x @ W + b). A batch of 32 images, each with 784 pixels, becomes 32 rows of 10 scores.
The TensorFlow Family
TensorFlow is not one tool. It is a family of tools that work together. You will meet most of them in this course.
What Happens When You Run One Line
Take this line: tf.add(a, b). Here is what TensorFlow does behind the scenes.
Eager Mode — The Default
Since TensorFlow 2, eager execution is on by default. Every op runs the moment you call it. You get real numbers back. It feels just like NumPy.
import tensorflow as tf
print("TensorFlow version:", tf.__version__)
print("Eager mode on? ", tf.executing_eagerly())
a = tf.constant([[1, 2], [3, 4]])
b = tf.constant([[10, 20], [30, 40]])
c = a + b # runs right now
print(c)
print("As NumPy:", c.numpy().tolist())
Because ops run at once, normal Python works fine. You can use if, for and print anywhere. That makes debugging easy.
x = tf.constant([3.0, -1.0, 4.0, -2.0])
for value in x: # plain Python loop
if value > 0:
print(float(value), "is positive")
else:
print(float(value), "is negative -> relu gives", float(tf.nn.relu(value)))
You see every value. You can set a breakpoint. Errors point to the exact line. Use eager mode while you learn, test and debug.
Try It — Eager vs Graph Simulator
Enter your own values. Then run the same small program in both modes. Watch how and when the values appear.
Program: y = relu(x * w + b). Eager mode runs each op one by one. Graph mode first builds the plan (no values yet), then runs it all in one go.
In eager mode, each box gets a value right after its step. In graph mode, all boxes show ? during tracing. They are symbolic tensors with a shape but no value. Values arrive only when the finished graph runs.
Graph Mode with tf.function
Add the @tf.function decorator to a Python function. TensorFlow then turns it into a graph. A graph is a saved plan of all the ops. TensorFlow can optimise this plan, run parts in parallel, and export it to phones or servers.
@tf.function
def dense_step(x, w, b):
print("Python print -> runs only while TRACING")
tf.print("tf.print -> runs on EVERY call")
return tf.nn.relu(x * w + b)
x = tf.constant(3.0); w = tf.constant(-2.0); b = tf.constant(4.0)
print("Call 1:"); dense_step(x, w, b)
print("Call 2:"); dense_step(x, w, b)
print("Call 3 (new value, same type):")
print("Result:", dense_step(tf.constant(1.0), w, b).numpy())
Look closely. The first call printed both lines. Calls 2 and 3 printed only the tf.print line. On the first call, TensorFlow ran your Python code once to trace it. After that, it reuses the graph and skips the Python code.
tf.print writes to standard error, not standard output. In Colab and Jupyter you will see its lines mixed in with normal output. Use tf.print inside a tf.function when you need to see values on every call.
When Does TensorFlow Trace Again?
A graph is tied to an input signature. For tensors, the signature is the shape and dtype. For plain Python numbers, the signature is the exact value. A new signature means a new trace.
@tf.function
def square(v):
print(" tracing for", v)
return v * v
print("Tensors:")
square(tf.constant(2)); square(tf.constant(5)) # same shape+dtype -> 1 trace
square(tf.constant(2.0)) # new dtype -> trace again
print("Python ints:")
square(2); square(5); square(2) # every new value -> trace
Try It — The Tracing Lab
Type an argument for square(v) and press Call. Try tf.constant(3), tf.constant(7), tf.constant(3.5), tf.constant([1, 2]), 4, 9.
Each trace costs time. If you pass Python numbers in a training loop, TensorFlow traces on every new value. Your "fast" graph becomes slower than eager mode. Always pass tensors into a tf.function.
Eager vs Graph — Side by Side
| Feature | Eager Mode | Graph Mode (tf.function) |
|---|---|---|
| When ops run | Right away, line by line | After the graph is traced |
| Debugging | Easy — print, breakpoints | Harder — use tf.print |
| Speed | Good | Faster for many small ops |
| Python control flow | Works as normal | Converted to graph ops by AutoGraph |
| Export (SavedModel, LiteRT) | No | Yes |
| Best for | Learning, testing, debugging | Training loops, production |
When you call model.fit(), Keras wraps the training step in a tf.function for you. So you get graph speed without writing any decorator.
Golden Rules
@tf.function only when it works.print runs only while tracing. Use tf.print to see values on every call..numpy() to turn an eager tensor into a NumPy array for printing or plotting.