ضمیمه C: ساختارهای داده ویژه و ضمیمه D: گرافهای TensorFlow
ضمیمه C: ساختارهای داده ویژه و ضمیمه D: گرافهای TensorFlow
ضمیمه C: Special Data Structures (ساختارهای داده ویژه)
در این ضمیمه، نگاهی سریع به ساختارهای دادهای خواهیم داشت که TensorFlow علاوه بر تنسورهای معمول اعشاری یا صحیح پشتیبانی میکند. این ساختارها شامل رشتهها، Ragged Tensor (تنسور ناهموار)، Sparse Tensor (تنسور تنک)، آرایههای تنسوری، مجموعهها و صفها هستند.
رشتهها
تنسورها میتوانند رشتههای بایتی نگه دارند؛ قابلیتی که بهویژه در پردازش زبان طبیعی مفید است:
>>> tf.constant(b"hello world")
<tf.Tensor: shape=(), dtype=string, numpy=b'hello world'>
اگر بخواهید تنسوری با یک رشته Unicode بسازید، TensorFlow آن را بهصورت خودکار با UTF-8 کدگذاری میکند:
>>> tf.constant("café")
<tf.Tensor: shape=(), dtype=string, numpy=b'caf\xc3\xa9'>
میتوانید تنسورهایی نیز بسازید که رشتههای Unicode را نمایش دهند. کافی است آرایهای از اعداد صحیح ۳۲ بیتی ایجاد کنید که هر عدد یک Unicode code point (نقطه کد یونیکد) را نشان دهد:1
>>> u = tf.constant([ord(c) for c in "café"])
>>> u
<tf.Tensor: shape=(4,), [...], numpy=array([ 99, 97, 102, 233], dtype=int32)>
بسته tf.strings چندین تابع برای کار با تنسورهای رشتهای دارد. تابع length() تعداد بایتهای یک رشته بایتی را میشمارد، یا اگر unit="UTF8_CHAR" باشد تعداد نقاط کد را میشمارد. تابع unicode_encode() یک تنسور رشته Unicode، یعنی تنسور int32، را به تنسور رشته بایتی تبدیل میکند و unicode_decode() تبدیل معکوس را انجام میدهد:
>>> b = tf.strings.unicode_encode(u, "UTF-8")
>>> b
<tf.Tensor: shape=(), dtype=string, numpy=b'caf\xc3\xa9'>
>>> tf.strings.length(b, unit="UTF8_CHAR")
<tf.Tensor: shape=(), dtype=int32, numpy=4>
>>> tf.strings.unicode_decode(b, "UTF-8")
<tf.Tensor: shape=(4,), [...], numpy=array([ 99, 97, 102, 233], dtype=int32)>
همچنین میتوانید تنسورهایی را که چند رشته در خود دارند پردازش کنید:
>>> p = tf.constant(["Café", "Coffee", "caffè", "咖啡"])
>>> tf.strings.length(p, unit="UTF8_CHAR")
<tf.Tensor: shape=(4,), dtype=int32, numpy=array([4, 6, 5, 2], dtype=int32)>
>>> r = tf.strings.unicode_decode(p, "UTF8")
>>> r
<tf.RaggedTensor [[67, 97, 102, 233], [67, 111, 102, 102, 101, 101], [99, 97,
102, 102, 232], [21654, 21857]]>
میبینید که رشتههای رمزگشاییشده داخل یک RaggedTensor نگهداری میشوند. در ادامه مفهوم آن را بررسی میکنیم.
Ragged Tensor (تنسور ناهموار)
تنسور ناهموار نوع ویژهای از تنسور است که فهرستی از آرایههایی با اندازههای متفاوت را نمایش میدهد. بهطور کلی، چنین تنسوری یک یا چند بُعد ناهموار دارد؛ یعنی برشهای آن بُعد میتوانند طولهای متفاوتی داشته باشند. در تنسور r بُعد دوم ناهموار است. در همه تنسورهای ناهموار، بُعد نخست همیشه معمولی یا یکنواخت است.
همه عناصر r خودشان تنسورهای معمولیاند. برای نمونه عنصر دوم:
>>> r[1]
<tf.Tensor: [...], numpy=array([ 67, 111, 102, 102, 101, 101], dtype=int32)>
بسته tf.ragged توابع متعددی برای ساخت و دستکاری این تنسورها دارد. تنسور ناهموار دوم را با tf.ragged.constant() میسازیم و آن را در محور صفر به تنسور نخست متصل میکنیم:
>>> r2 = tf.ragged.constant([[65, 66], [], [67]])
>>> tf.concat([r, r2], axis=0)
<tf.RaggedTensor [[67, 97, 102, 233], [67, 111, 102, 102, 101, 101], [99, 97,
102, 102, 232], [21654, 21857], [65, 66], [], [67]]>
نتیجه طبیعی است: تنسورهای r2 در امتداد محور صفر پس از تنسورهای r قرار گرفتهاند. حال اگر r و یک تنسور ناهموار دیگر را در محور یک به هم متصل کنیم چه میشود؟
>>> r3 = tf.ragged.constant([[68, 69, 70], [71], [], [72, 73]])
>>> print(tf.concat([r, r3], axis=1))
<tf.RaggedTensor [[67, 97, 102, 233, 68, 69, 70], [67, 111, 102, 102, 101, 101,
71], [99, 97, 102, 102, 232], [21654, 21857, 72, 73]]>
اینبار تنسور iام در r با تنسور iام در r3 متصل شده است. این حالت غیرمعمولتر است، زیرا همه این تنسورها میتوانند طولهای متفاوت داشته باشند.
با فراخوانی to_tensor()، تنسور ناهموار به تنسور معمولی تبدیل میشود و تنسورهای کوتاهتر با صفر پُر میشوند تا همه طول یکسان داشته باشند. مقدار پیشفرض را میتوان با آرگومان default_value تغییر داد:
>>> r.to_tensor()
<tf.Tensor: shape=(4, 6), dtype=int32, numpy=
array([[ 67, 97, 102, 233, 0, 0],
[ 67, 111, 102, 102, 101, 101],
[ 99, 97, 102, 102, 232, 0],
[21654, 21857, 0, 0, 0, 0]], dtype=int32)>
بسیاری از عملیات TensorFlow از تنسورهای ناهموار پشتیبانی میکنند. فهرست کامل در مستندات کلاس tf.RaggedTensor آمده است.
Sparse Tensor (تنسور تنک)
TensorFlow میتواند تنسورهای تنک، یعنی تنسورهایی که بیشتر عناصر آنها صفر است، را بهصورت کارآمد نمایش دهد. برای ساخت tf.SparseTensor باید اندیس و مقدار عناصر غیرصفر و شکل تنسور را مشخص کنید. اندیسها باید با ترتیب خواندن، از چپ به راست و از بالا به پایین، نوشته شوند. اگر مطمئن نیستید از tf.sparse.reorder() استفاده کنید. تبدیل تنسور تنک به تنسور چگال با tf.sparse.to_dense() انجام میشود:
>>> s = tf.SparseTensor(indices=[[0, 1], [1, 0], [2, 3]],
... values=[1., 2., 3.],
... dense_shape=[3, 4])
...
>>> tf.sparse.to_dense(s)
<tf.Tensor: shape=(3, 4), dtype=float32, numpy=
array([[0., 1., 0., 0.],
[2., 0., 0., 0.],
[0., 0., 0., 3.]], dtype=float32)>
تنسور تنک بهاندازه تنسور چگال از عملیات مختلف پشتیبانی نمیکند. برای نمونه میتوانید آن را در یک مقدار اسکالر ضرب کنید و تنسور تنک دیگری بگیرید، اما افزودن اسکالر مجاز نیست، چون نتیجه دیگر تنک نخواهد بود:
>>> s * 42.0
<tensorflow.python.framework.sparse_tensor.SparseTensor at 0x7f84a6749f10>
>>> s + 42.0
[...] TypeError: unsupported operand type(s) for +: 'SparseTensor' and 'float'
TensorArray (آرایه تنسوری)
tf.TensorArray فهرستی از تنسورها را نمایش میدهد. در مدلهای پویا و دارای حلقه، برای انباشتن نتایج و سپس محاسبه آمار روی آنها مفید است. در هر موقعیت آرایه میتوان تنسور نوشت یا خواند:
array = tf.TensorArray(dtype=tf.float32, size=3)
array = array.write(0, tf.constant([1., 2.]))
array = array.write(1, tf.constant([3., 10.]))
array = array.write(2, tf.constant([5., 7.]))
tensor1 = array.read(1) # => returns (and zeros out!) tf.constant([3., 10.])
بهطور پیشفرض، خواندن یک عضو آن را نیز با تنسوری همشکل و پُر از صفر جایگزین میکند. اگر این رفتار را نمیخواهید، clear_after_read را روی False قرار دهید.
اندازه TensorArray بهطور پیشفرض هنگام ساخت ثابت است. میتوانید size=0 و dynamic_size=True تعیین کنید تا آرایه در صورت نیاز رشد کند، اما این کار به کارایی آسیب میزند؛ بنابراین اگر اندازه را از پیش میدانید، آرایه با اندازه ثابت بهتر است. نوع داده نیز باید مشخص شود و همه عناصر باید همان شکلی را داشته باشند که نخستین عنصر نوشتهشده دارد.
با stack() میتوان همه اعضا را در یک تنسور معمولی روی هم چید:
>>> array.stack()
<tf.Tensor: shape=(3, 2), dtype=float32, numpy=
array([[1., 2.],
[0., 0.],
[5., 7.]], dtype=float32)>
مجموعهها
TensorFlow از مجموعههای عدد صحیح یا رشته پشتیبانی میکند، اما از مجموعه اعداد اعشاری پشتیبانی نمیکند. مجموعهها با تنسورهای معمولی نمایش داده میشوند. برای نمونه مجموعه {1, 5, 9} بهصورت تنسور [[1, 5, 9]] نمایش داده میشود. تنسور باید دستکم دو بُعد داشته باشد و مجموعهها باید در بُعد آخر قرار گیرند. بنابراین [[1, 5, 9], [2, 5, 11]] دو مجموعه مستقل را نگه میدارد.
بسته tf.sets چند تابع برای کار با مجموعهها دارد. برای نمونه، اجتماع دو مجموعه را محاسبه میکنیم. چون نتیجه تنسور تنک است، برای نمایش آن to_dense() را فراخوانی میکنیم:
>>> a = tf.constant([[1, 5, 9]])
>>> b = tf.constant([[5, 6, 9, 11]])
>>> u = tf.sets.union(a, b)
>>> u
<tensorflow.python.framework.sparse_tensor.SparseTensor at 0x132b60d30>
>>> tf.sparse.to_dense(u)
<tf.Tensor: [...], numpy=array([[ 1, 5, 6, 9, 11]], dtype=int32)>
میتوان اجتماع چند جفت مجموعه را همزمان محاسبه کرد. اگر بعضی مجموعهها کوتاهترند باید آنها را با مقداری مانند صفر پُر کنید:
>>> a = tf.constant([[1, 5, 9], [10, 0, 0]])
>>> b = tf.constant([[5, 6, 9, 11], [13, 0, 0, 0]])
>>> u = tf.sets.union(a, b)
>>> tf.sparse.to_dense(u)
<tf.Tensor: [...] numpy=array([[ 1, 5, 6, 9, 11],
[ 0, 10, 13, 0, 0]], dtype=int32)>
اگر میخواهید مقدار دیگری مانند -1 برای پُرکردن استفاده کنید، هنگام فراخوانی to_dense() آرگومان default_value=-1 را تنظیم کنید. مقدار پیشفرض default_value صفر است؛ بنابراین برای مجموعههای رشتهای باید این پارامتر را، برای نمونه به رشته خالی، تغییر دهید.
از دیگر توابع tf.sets میتوان difference()، intersection() و size() را نام برد. برای بررسی اینکه یک مجموعه شامل مقادیر مشخصی هست یا نه، میتوان اشتراک آن مجموعه و مقادیر را محاسبه کرد. برای افزودن مقادیر نیز میتوان اجتماع مجموعه با آن مقادیر را بهدست آورد.
صفها
صف ساختار دادهای است که رکوردها را در آن وارد میکنید و بعداً بیرون میکشید. TensorFlow چند نوع صف را در بسته tf.queue پیادهسازی میکند. این صفها زمانی برای ساخت خط لولههای کارآمد بارگذاری و پیشپردازش داده بسیار مهم بودند، اما API مربوط به tf.data تقریباً آنها را، جز در موارد نادر، غیرضروری کرده است؛ زیرا سادهتر است و ابزارهای موردنیاز برای خط لوله کارآمد را فراهم میکند. بااینحال برای کاملبودن بحث، مروری کوتاه داریم.
سادهترین نوع، صف FIFO یا «اولین ورودی، اولین خروجی» است. هنگام ساخت باید حداکثر تعداد رکوردهای قابل نگهداری را تعیین کنید. هر رکورد یک تاپل از تنسورها است، پس نوع هر تنسور و در صورت نیاز شکل آن را نیز مشخص میکنید. نمونه زیر صفی با حداکثر سه رکورد میسازد که هر رکورد شامل یک عدد صحیح ۳۲ بیتی و یک رشته است؛ سپس دو رکورد وارد میکند، اندازه را میخواند و یک رکورد بیرون میکشد:
>>> q = tf.queue.FIFOQueue(3, [tf.int32, tf.string], shapes=[(), ()])
>>> q.enqueue([10, b"windy"])
>>> q.enqueue([15, b"sunny"])
>>> q.size()
<tf.Tensor: shape=(), dtype=int32, numpy=2>
>>> q.dequeue()
[<tf.Tensor: shape=(), dtype=int32, numpy=10>,
<tf.Tensor: shape=(), dtype=string, numpy=b'windy'>]
با enqueue_many() و dequeue_many() میتوان چند رکورد را همزمان وارد یا خارج کرد. برای dequeue_many() باید هنگام ساخت صف آرگومان shapes مشخص شده باشد:
>>> q.enqueue_many([[13, 16], [b'cloudy', b'rainy']])
>>> q.dequeue_many(3)
[<tf.Tensor: [...], numpy=array([15, 13, 16], dtype=int32)>,
<tf.Tensor: [...], numpy=array([b'sunny', b'cloudy', b'rainy'], dtype=object)>]
انواع دیگر صف عبارتاند از:
PaddingFIFOQueue- مانند
FIFOQueue است، اما dequeue_many() آن میتواند چند رکورد با شکلهای متفاوت را خارج کند. رکوردهای کوتاهتر بهصورت خودکار پُر میشوند تا همه رکوردهای یک دسته شکل یکسان داشته باشند.
PriorityQueue- رکوردها را بر اساس اولویت خارج میکند. اولویت باید عدد صحیح ۶۴ بیتی باشد و عنصر اول هر رکورد قرار گیرد. برخلاف آنچه شاید انتظار داشته باشید، رکورد با عدد اولویت کوچکتر زودتر خارج میشود و رکوردهای هماولویت با ترتیب FIFO خارج میشوند.
RandomShuffleQueue- رکوردها را با ترتیب تصادفی خارج میکند. پیش از وجود
tf.data برای پیادهسازی بافر درهمسازی مفید بود.
اگر صف پُر باشد و بخواهید رکورد دیگری وارد کنید، متد enqueue*() تا زمانی که رشته دیگری رکوردی را خارج کند متوقف میماند. به همین شکل، اگر صف خالی باشد و dequeue*() را فراخوانی کنید، اجرای آن تا ورود رکورد از یک رشته دیگر متوقف میشود.
ضمیمه D: TensorFlow Graphs (گرافهای TensorFlow)
در این ضمیمه، گرافهایی را بررسی میکنیم که TF function (تابع TensorFlow) تولید میکند.
TF Function و Concrete Function
توابع TensorFlow چندریخت هستند؛ یعنی ورودیهایی با نوعها و شکلهای مختلف را پشتیبانی میکنند. تابع زیر را در نظر بگیرید:
@tf.function
def tf_cube(x):
return x ** 3
هر بار که یک TF function را با ترکیب جدیدی از نوع یا شکل ورودیها فراخوانی میکنید، یک ConcreteFunction (تابع عینی) جدید با گرافی ویژه همان ترکیب ساخته میشود. ترکیب نوع و شکل آرگومانها «امضای ورودی» نام دارد. اگر تابع را با امضایی که قبلاً دیده است فراخوانی کنید، همان تابع عینی قبلی دوباره استفاده میشود.
برای نمونه، فراخوانی tf_cube(tf.constant(3.0)) همان تابع عینیای را بهکار میبرد که برای tf_cube(tf.constant(2.0)) ساخته شده بود؛ زیرا هر دو تنسور اسکالر float32 هستند. اما فراخوانی با tf.constant([2.0]) یا tf.constant([3.0]) تابع عینی دیگری برای شکل [1] میسازد و فراخوانی با آرایه دوبعدی [[1.0,2.0],[3.0,4.0]] تابع عینی دیگری برای شکل [2,2] تولید میکند.
با get_concrete_function() میتوانید تابع عینی مربوط به یک ترکیب خاص از ورودیها را دریافت کنید. این تابع مانند یک تابع معمولی فراخوانی میشود، اما فقط از همان امضای ورودی پشتیبانی میکند:
>>> concrete_function = tf_cube.get_concrete_function(tf.constant(2.0))
>>> concrete_function
<ConcreteFunction tf_cube(x) at 0x7F84411F4250>
>>> concrete_function(tf.constant(2.0))
<tf.Tensor: shape=(), dtype=float32, numpy=8.0>
شکل D-1 تابع tf_cube() را پس از فراخوانی با مقدار Python برابر ۲ و با tf.constant(2.0) نشان میدهد. دو تابع عینی، یکی برای هر امضا، ساخته شدهاند و هرکدام FuncGraph (گراف تابع) بهینهشده و FunctionDef (تعریف تابع) مخصوص خود را دارند. تعریف تابع بخشهایی از گراف را مشخص میکند که با ورودیها و خروجیها متناظرند. در هر FuncGraph، گرهها عملیاتهایی مانند توان، ثابتها یا Placeholder (جاینگهدار) آرگومانها را نشان میدهند و یالها تنسورهایی هستند که در گراف جریان پیدا میکنند.
تابع عینی سمت چپ برای x=2 تخصصی شده و TensorFlow آن را چنان ساده کرده است که همیشه مقدار ۸ را خروجی میدهد؛ حتی تعریف تابع آن ورودی ندارد. تابع سمت راست برای تنسور اسکالر float32 تخصصی شده و قابل سادهسازی کامل نیست. اگر tf_cube(tf.constant(5.0)) را فراخوانی کنیم، Placeholder مقدار ۵ را خروجی میدهد و عمل توان 5.0 ** 3 را محاسبه میکند تا ۱۲۵ حاصل شود.
شکل D-1. تابع tf_cube() همراه با ConcreteFunctionها و FuncGraphهای آن
تنسورهای این گرافها «تنسور نمادین» هستند؛ یعنی مقدار واقعی ندارند و فقط نوع داده، شکل و نام دارند. آنها تنسورهای آینده را نمایش میدهند که پس از تغذیه یک مقدار واقعی به Placeholder و اجرای گراف، در گراف جریان مییابند. تنسورهای نمادین اجازه میدهند اتصال عملیات از پیش تعریف شود و TensorFlow نیز بتواند نوع و شکل همه تنسورها را بهصورت بازگشتی از نوع و شکل ورودیهای آنها استنتاج کند.
کاوش تعریف تابع و گراف
گراف محاسباتی تابع عینی از ویژگی graph در دسترس است و فهرست عملیات آن با get_operations() دریافت میشود:
>>> concrete_function.graph
<tensorflow.python.framework.func_graph.FuncGraph at 0x7f84411f4790>
>>> ops = concrete_function.graph.get_operations()
>>> ops
[<tf.Operation 'x' type=Placeholder>,
<tf.Operation 'pow/y' type=Const>,
<tf.Operation 'pow' type=Pow>,
<tf.Operation 'Identity' type=Identity>]
در این مثال، عملیات اول آرگومان ورودی x را نمایش میدهد و Placeholder نام دارد؛ عملیات دوم ثابت ۳، عملیات سوم عمل توان ** و عملیات آخر خروجی تابع را نشان میدهد. عملیات آخر Identity است و صرفاً خروجی عمل توان را کپی میکند.2 هر عملیات فهرستی از تنسورهای ورودی و خروجی دارد که از ویژگیهای inputs و outputs قابل دسترسیاند:
>>> pow_op = ops[2]
>>> list(pow_op.inputs)
[<tf.Tensor 'x:0' shape=() dtype=float32>,
<tf.Tensor 'pow/y:0' shape=() dtype=float32>]
>>> pow_op.outputs
[<tf.Tensor 'pow:0' shape=() dtype=float32>]
این گراف در شکل D-2 نمایش داده شده است.
شکل D-2. نمونهای از یک گراف محاسباتی
هر عملیات یک نام دارد. نام پیشفرض معمولاً همان نام عملیات، برای نمونه pow، است؛ اما میتوانید هنگام فراخوانی آن نام را دستی تعیین کنید، مانند tf.pow(x, 3, name="other_name"). اگر نام از پیش وجود داشته باشد، TensorFlow بهصورت خودکار یک اندیس یکتا مانند pow_1 یا pow_2 اضافه میکند.
هر تنسور نیز نامی یکتا دارد: نام عملیاتی که آن تنسور را تولید میکند، بهعلاوه :0 برای نخستین خروجی، :1 برای دومین خروجی و به همین ترتیب. با get_operation_by_name() یا get_tensor_by_name() میتوانید عملیات یا تنسور را با نام دریافت کنید:
>>> concrete_function.graph.get_operation_by_name('x')
<tf.Operation 'x' type=Placeholder>
>>> concrete_function.graph.get_tensor_by_name('Identity:0')
<tf.Tensor 'Identity:0' shape=() dtype=float32>
تابع عینی همچنین تعریف تابع را در قالب Protocol Buffer نگه میدارد؛ این تعریف شامل امضای تابع است. امضا به تابع عینی میگوید کدام Placeholderها باید با ورودیها مقداردهی شوند و چه تنسورهایی باید بازگردانده شوند:
>>> concrete_function.function_def.signature
name: "__inference_tf_cube_3515903"
input_arg {
name: "x"
type: DT_FLOAT
}
output_arg {
name: "identity"
type: DT_FLOAT
}
نگاهی دقیقتر به Tracing (ردیابی)
تابع tf_cube() را کمی تغییر میدهیم تا ورودی خود را چاپ کند:
@tf.function
def tf_cube(x):
print(f"x = {x}")
return x ** 3
اکنون آن را فراخوانی میکنیم:
>>> result = tf_cube(tf.constant(2.0))
x = Tensor("x:0", shape=(), dtype=float32)
>>> result
<tf.Tensor: shape=(), dtype=float32, numpy=8.0>
نتیجه درست است، اما آنچه چاپ شد را ببینید: x یک تنسور نمادین است؛ شکل و نوع داده دارد اما مقدار ندارد و نام آن x:0 است. دلیل این است که print() یک عملیات TensorFlow نیست و فقط زمانی اجرا میشود که تابع Python ردیابی شود. ردیابی در حالت گراف رخ میدهد و آرگومانها با تنسورهای نمادین همنوع و همشکل اما بدون مقدار جایگزین میشوند. چون print() داخل گراف ثبت نشده، فراخوانیهای بعدی با تنسور اسکالر float32 چیزی چاپ نمیکنند:
>>> result = tf_cube(tf.constant(3.0))
>>> result = tf_cube(tf.constant(4.0))
اما اگر نوع یا شکل تنسور متفاوت باشد یا مقدار Python جدیدی ارسال شود، تابع دوباره ردیابی میشود و print() اجرا خواهد شد:
>>> result = tf_cube(2) # new Python value: trace!
x = 2
>>> result = tf_cube(3) # new Python value: trace!
x = 3
>>> result = tf_cube(tf.constant([[1., 2.]])) # new shape: trace!
x = Tensor("x:0", shape=(1, 2), dtype=float32)
>>> result = tf_cube(tf.constant([[3., 4.], [5., 6.]])) # new shape: trace!
x = Tensor("x:0", shape=(None, 2), dtype=float32)
>>> result = tf_cube(tf.constant([[7., 8.], [9., 10.]])) # same shape: no trace
گاهی لازم است TF function را به یک امضای ورودی مشخص محدود کنید. فرض کنید تابع فقط با دستههایی از تصاویر ۲۸×۲۸ پیکسلی فراخوانی میشود، اما اندازه دستهها بسیار متفاوت است. شاید نخواهید TensorFlow برای هر اندازه دسته تابع عینی جدیدی بسازد. در این حالت امضای ورودی را صریح تعیین میکنید:
@tf.function(input_signature=[tf.TensorSpec([None, 28, 28], tf.float32)])
def shrink(images):
return images[:, ::2, ::2] # drop half the rows and columns
این تابع هر تنسور float32 با شکل [*, 28, 28] را میپذیرد و هر بار همان تابع عینی را بهکار میبرد:
img_batch_1 = tf.random.uniform(shape=[100, 28, 28])
img_batch_2 = tf.random.uniform(shape=[50, 28, 28])
preprocessed_images = shrink(img_batch_1) # works fine, traces the function
preprocessed_images = shrink(img_batch_2) # works fine, same concrete function
اگر تابع را با مقدار Python یا تنسوری با نوع یا شکل غیرمنتظره فراخوانی کنید، استثنا رخ میدهد:
img_batch_3 = tf.random.uniform(shape=[2, 2, 2])
preprocessed_images = shrink(img_batch_3) # ValueError! Incompatible inputs
استفاده از AutoGraph برای ثبت جریان کنترل
اگر تابع شما یک حلقه ساده for داشته باشد چه اتفاقی میافتد؟ تابع زیر با افزودن عدد ۱ در ده تکرار، مقدار ۱۰ را به ورودی اضافه میکند:
@tf.function
def add_10(x):
for i in range(10):
x += 1
return x
تابع درست کار میکند، اما گراف آن حلقه ندارد و فقط شامل ده عملیات جمع است:
>>> add_10(tf.constant(0))
<tf.Tensor: shape=(), dtype=int32, numpy=15>
>>> add_10.get_concrete_function(tf.constant(0)).graph.get_operations()
[<tf.Operation 'x' type=Placeholder>, [...],
<tf.Operation 'add' type=AddV2>, [...],
<tf.Operation 'add_1' type=AddV2>, [...],
<tf.Operation 'add_2' type=AddV2>, [...],
[...]
<tf.Operation 'add_9' type=AddV2>, [...],
<tf.Operation 'Identity' type=Identity>]
این رفتار منطقی است: وقتی تابع ردیابی شد، حلقه ده بار اجرا شد و عمل x += 1 ده بار در گراف ثبت شد. میتوان این حلقه را «ایستا» دانست که هنگام ساخت گراف باز میشود.
اگر حلقه «پویا» میخواهید، یعنی هنگام اجرای گراف تکرار شود، میتوانید دستی از tf.while_loop() استفاده کنید، اما روش شهودیتری وجود دارد: AutoGraph. این قابلیت بهطور پیشفرض فعال است و در صورت نیاز میتوان با autograph=False در tf.function() غیرفعالش کرد. AutoGraph فقط حلقههای for را که روی تنسورها یا tf.data.Dataset پیمایش میکنند ثبت میکند، پس برای حلقه پویا باید از tf.range() بهجای range() استفاده کنید.
- با
range() حلقه ایستا است، فقط هنگام ردیابی اجرا میشود و هر تکرار به مجموعهای از عملیات جداگانه در گراف باز میشود. - با
tf.range() حلقه پویا است، خود حلقه داخل گراف قرار میگیرد و هنگام ردیابی اجرا نمیشود.
اگر در add_10() فقط range() را با tf.range() جایگزین کنیم، گراف یک عملیات While خواهد داشت:
>>> add_10.get_concrete_function(tf.constant(0)).graph.get_operations()
[<tf.Operation 'x' type=Placeholder>, [...],
<tf.Operation 'while' type=StatelessWhile>, [...]]
در نتیجه گراف مانند حالتی است که tf.while_loop() را فراخوانی کرده باشیم.
مدیریت متغیرها و منابع دیگر در TF Function
در TensorFlow، متغیرها و اشیای دارای حالت مانند صفها یا مجموعهدادهها «منبع» نامیده میشوند. TF function با آنها با دقت ویژه رفتار میکند: هر عملیاتی که یک منبع را بخواند یا بهروزرسانی کند دارای حالت محسوب میشود و TensorFlow تضمین میکند این عملیات به ترتیبی که نوشته شدهاند اجرا شوند. در مقابل، عملیات بیحالت ممکن است موازی اجرا شوند و ترتیبشان تضمینشده نیست.
وقتی یک منبع را بهعنوان آرگومان به TF function میدهید، با ارجاع منتقل میشود و تابع میتواند آن را تغییر دهد:
counter = tf.Variable(0)
@tf.function
def increment(counter, c=1):
return counter.assign_add(c)
increment(counter) # counter is now equal to 1
increment(counter) # counter is now equal to 2
در تعریف تابع، آرگومان نخست بهعنوان منبع علامتگذاری میشود:
>>> function_def = increment.get_concrete_function(counter).function_def
>>> function_def.signature.input_arg[0]
name: "counter"
type: DT_RESOURCE
همچنین میتوان از tf.Variable تعریفشده بیرون تابع بدون ارسال صریح آن بهعنوان آرگومان استفاده کرد:
counter = tf.Variable(0)
@tf.function
def increment(c=1):
return counter.assign_add(c)
TF function این متغیر را مانند یک آرگومان نخست ضمنی در نظر میگیرد و در نهایت تقریباً همان امضای قبلی را خواهد داشت. بااینحال، متغیرهای سراسری بهسرعت کد را آشفته میکنند؛ بنابراین بهتر است متغیرها و منابع را داخل کلاسها قرار دهید. @tf.function با متدها نیز بهخوبی کار میکند:
class Counter:
def __init__(self):
self.counter = tf.Variable(0)
@tf.function
def increment(self, c=1):
return self.counter.assign_add(c)
Keras نمونه مناسبی از این رویکرد شیءگرا است.
استفاده از TF Function همراه Keras، یا غیرفعالکردن آن
بهطور پیشفرض هر تابع، لایه یا مدل سفارشی که با Keras استفاده کنید بهصورت خودکار به TF function تبدیل میشود و نیازی به کار اضافی نیست. در بعضی حالتها ممکن است بخواهید این تبدیل را غیرفعال کنید؛ برای نمونه وقتی کد سفارشی قابل تبدیل به TF function نیست یا میخواهید آن را اشکالزدایی کنید که در حالت eager آسانتر است. میتوانید هنگام ساخت مدل یا لایه، dynamic=True بدهید:
model = MyModel(dynamic=True)
اگر مدل یا لایه سفارشی همیشه پویا خواهد بود، سازنده کلاس پایه را با dynamic=True فراخوانی کنید:
class MyDense(tf.keras.layers.Layer):
def __init__(self, units, **kwargs):
super().__init__(dynamic=True, **kwargs)
[...]
راه دیگر این است که هنگام compile() مقدار run_eagerly=True تعیین کنید:
model.compile(loss=my_mse, optimizer="nadam", metrics=[my_mae],
run_eagerly=True)
اکنون دیدیم TF function چگونه چندریختی را با چند تابع عینی مدیریت میکند، گرافها با AutoGraph و ردیابی چگونه ساخته میشوند، گراف از چه عملیات و تنسورهای نمادینی تشکیل شده است، منابع و متغیرها چگونه مدیریت میشوند و TF function چگونه با Keras بهکار میرود.