انتقل إلى المحتوى الرئيسي

الوحدة 7 — رفع الملفّات وتنزيلها

قدرة تطبيقنا حتّى الآن على تسجيل عميل واحد كافية للاختبار، لكنّها لا تخدم فريق مبيعات يستقبل ملفّ CSV بألف عميل يوميًّا. هذه الوحدة تضيف الرفع (Streamlit → ملفّ يصل إلى الخادم)، والتحقّق من محتواه، والتسجيل الدفعيّ، وتنزيل النتائج. كلّ هذه العناصر بسيطة بمكوّناتها، لكنّ كلّ واحدة تُخفي فخًّا يظهر في الإنتاج فقط.

st.file_uploader: الأساس

المكوّن الأبسط والأكثر استعمالًا:

import streamlit as st

fichier = st.file_uploader(
"ارفع ملفّ عملاء (CSV)",
type=["csv"],
accept_multiple_files=False,
)

if fichier is not None:
st.write(f"استُلم الملفّ: {fichier.name}, حجم {fichier.size / 1024:.1f} كيلو‌بايت")

fichier هو كائن UploadedFile يتصرّف كملفّ مفتوح: .read() يعيد البايتات، .getvalue() كذلك، .name اسم الملفّ الأصليّ، .size حجمه. مهمّ: الملفّ لا يُكتب على قرص الخادم؛ يبقى في الذاكرة. هذا خيار تصميم Streamlit، وله فوائد أمنيّة (لا تسرّب) وحدود عمليّة (لا يمكن رفع 5 غيغابايت).

نمرّره لـpandas مباشرة:

import pandas as pd
if fichier is not None:
df = pd.read_csv(fichier)
st.dataframe(df.head())

pd.read_csv يقبل الكائنات الشبيهة بالملفّات بلا مشكلة.

قراءات متعدّدة الأنواع

type=["csv", "xlsx", "parquet"] يقبل عدّة صيغ. الاستعمال:

fichier = st.file_uploader("ملفّ عملاء", type=["csv", "xlsx", "parquet"])

if fichier is not None:
if fichier.name.endswith(".csv"):
df = pd.read_csv(fichier)
elif fichier.name.endswith(".xlsx"):
df = pd.read_excel(fichier) # يستوجب openpyxl مثبَّتة
else:
df = pd.read_parquet(fichier)
st.dataframe(df.head())

قاعدة عمليّة: قبِل الصيغة الأكثر ملاءمة لجمهورك، لا كلّ الصيغ التقنيّة. فريق مبيعات يعرف Excel؛ فريق بيانات يفضّل Parquet. اقتصر على ما يُستعمَل فعلًا.

التحقّق من الأعمدة

الخطأ الأشيع في تطبيقات Streamlit التي تستقبل CSV من مستخدمين: عمود مفقود، اسم مختلف بحرف، نوع خاطئ. بلا تحقّق، السطر التالي يرفع استثناء غير مفهوم. مع تحقّق صريح، الرسالة تُخبر المستخدم ماذا يفعل.

COLONNES_ATTENDUES = {
"identifiant": "object",
"age": "int64",
"anciennete_mois": "int64",
"offre": "object",
"mensuel_usd": "float64",
}

def valider_dataframe(df: pd.DataFrame) -> list[str]:
erreurs = []
for col, dtype in COLONNES_ATTENDUES.items():
if col not in df.columns:
erreurs.append(f"عمود ناقص: {col}")
elif str(df[col].dtype) != dtype:
erreurs.append(f"نوع {col} خاطئ: متوقّع {dtype}, وُجد {df[col].dtype}")
if df.empty:
erreurs.append("الملفّ فارغ")
if len(df) > 50_000:
erreurs.append(f"الملفّ كبير جدًّا: {len(df)} صفوف، الحدّ 50 000")
return erreurs

استعمال في التطبيق:

if fichier is not None:
df = pd.read_csv(fichier)
erreurs = valider_dataframe(df)
if erreurs:
for err in erreurs:
st.error(err)
st.stop()
st.success(f"تحقّق ناجح على {len(df)} صفًّا.")

st.stop() يوقف تنفيذ السكربت في هذه النقطة. مفيد لتفادي معالجة إطار خاطئ.

التسجيل الدفعيّ

بعد تحقّق ناجح، ندرِّج كلّ العملاء بالنموذج المُحمَّل:

@st.cache_resource
def charger_modele():
import joblib
return joblib.load("modele_churn.joblib")

def scorer_lot(df: pd.DataFrame) -> pd.DataFrame:
modele = charger_modele()
X = df[["age", "anciennete_mois", "mensuel_usd"]]
# تحويل العرض إلى ترميز عدديّ يوافق ما دُرِّب عليه النموذج
encodage = {"Basic": 0, "Standard": 1, "Premium": 2, "Family": 3}
X = X.assign(offre_code=df["offre"].map(encodage))
scores = modele.predict_proba(X[["age", "anciennete_mois", "mensuel_usd", "offre_code"]])[:, 1]
resultat = df.copy()
resultat["score_risque"] = scores.round(3)
resultat["a_risque"] = resultat["score_risque"] > 0.5
return resultat

if st.button("سجّل الدفعة"):
with st.spinner(f"جارٍ تسجيل {len(df)} عميل..."):
resultat = scorer_lot(df)
st.success(f"سُجِّل {len(resultat)} عميل، منهم {int(resultat['a_risque'].sum())} بخطر.")
st.dataframe(resultat.head(20), use_container_width=True)

st.spinner يعرض مؤشّر تحميل مع نصّ خلال العمل. مفيد للعمليّات التي تتجاوز نصف ثانية.

للتسجيل التدريجيّ مع شريط تقدّم:

if st.button("سجّل الدفعة (بتقدّم)"):
barre = st.progress(0, text="بدء التسجيل...")
lots = np.array_split(df, 20)
resultats = []
for i, lot in enumerate(lots):
resultats.append(scorer_lot(lot))
barre.progress((i + 1) / len(lots), text=f"سُجِّل {sum(len(r) for r in resultats)} عميل")
resultat = pd.concat(resultats)
barre.empty()
st.success(f"اكتمل التسجيل على {len(resultat)} صفٍّ.")

هذا مفيد لملفّات كبيرة (10 000 صفٍّ فأكثر) حيث المستخدم يحتاج رؤية تقدّم فعليّ.

زرّ التنزيل (st.download_button)

بعد التسجيل، نُقدِّم نتائج قابلة للتنزيل:

csv_bytes = resultat.to_csv(index=False).encode("utf-8-sig")

st.download_button(
label="تنزيل النتائج (CSV)",
data=csv_bytes,
file_name="scoring_clients.csv",
mime="text/csv",
)

ثلاث نقاط دقيقة. data يقبل بايتات (bytes) أو نصًّا (str)؛ للـCSV العربيّ نستعمل utf-8-sig (يُضيف BOM يجعل Excel يفتحه صحيحًا). mime يُخبر المتصفّح نوع الملفّ. file_name الاسم الذي يقترحه المتصفّح.

لملفّ Excel:

from io import BytesIO
buffer = BytesIO()
resultat.to_excel(buffer, index=False, engine="openpyxl")
buffer.seek(0)

st.download_button(
label="تنزيل النتائج (Excel)",
data=buffer,
file_name="scoring_clients.xlsx",
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)

مهمّ: زرّ التنزيل يُنشّط إعادة تنفيذ عند النقر. إذا كان توليد الملفّ مكلفًا، خزّنه في session_state أو استعمل @st.cache_data على دالّة التوليد لتفادي إعادة الحساب.

حدود الحجم

Streamlit يفرض حدًّا افتراضيًّا على حجم الرفع: 200 ميغابايت لكلّ ملفّ. للتغيير، في .streamlit/config.toml:

[server]
maxUploadSize = 500

لا ترفع هذا الحدّ بلا سبب. ملفّات أكبر من 200 ميغابايت تُشير غالبًا إلى مشكلة تصميم: يجب أن يُرفع الملفّ إلى تخزين (S3, GCS) خارج التطبيق، ثمّ يُشير الرفع في Streamlit إلى معرّف. حساب الذاكرة صعب في Streamlit؛ ملفّ 500 ميغابايت مضروبًا في عشرة مستخدمين متزامنين = 5 غيغابايت في الرام، وسقوط التطبيق.

قاعدة عمليّة: لملفّات فوق 20 ميغابايت، فكِّر في تخزين خارجيّ. Streamlit مثاليّ للتفاعليّة، لا لنقل الملفّات الكبيرة.

استفسار سرّيّ عن محتوى الملفّ

الملفّ المرفوع يبقى في ذاكرة الخادم. إذا كان تطبيقك عامًّا، فتحت ثغرة أمنيّة: مستخدم قد يرفع ملفًّا خبيثًا (CSV بصيغة قد تُنفّذ عبر ثغرة pd.read_csv)، أو ملفًّا كبيرًا يستنفد الذاكرة (Denial of Service). ثلاث دفاعات إلزاميّة في الإنتاج: تحقّق صارم من الحجم قبل القراءة (if fichier.size > 20_000_000: st.error(...); st.stop())، تحقّق من الأعمدة قبل أيّ استعمال، وتحديد صارم للأنواع في read_csv (dtype={...}) لتجنّب استنتاجات pandas غير المتوقّعة.

بنية الوحدة الكاملة

import streamlit as st
import pandas as pd

st.header("تسجيل دفعة عملاء")

fichier = st.file_uploader("ملفّ CSV", type=["csv"])

if fichier is None:
st.info("ارفع ملفّ CSV يحوي أعمدة: identifiant, age, anciennete_mois, offre, mensuel_usd")
st.stop()

if fichier.size > 20_000_000:
st.error("الملفّ أكبر من 20 ميغابايت. قسّمه إلى ملفّات أصغر.")
st.stop()

df = pd.read_csv(fichier)
erreurs = valider_dataframe(df)
if erreurs:
for e in erreurs:
st.error(e)
st.stop()

st.success(f"جاهز لتسجيل {len(df)} عميل.")
st.dataframe(df.head(), use_container_width=True)

if st.button("سجّل الدفعة"):
with st.spinner("جارٍ التسجيل..."):
resultat = scorer_lot(df)
st.session_state.resultat_lot = resultat

if "resultat_lot" in st.session_state:
resultat = st.session_state.resultat_lot
st.success(f"سُجِّل {len(resultat)}، منهم {int(resultat['a_risque'].sum())} بخطر.")
st.dataframe(resultat.head(20), use_container_width=True)
csv_bytes = resultat.to_csv(index=False).encode("utf-8-sig")
st.download_button("تنزيل النتائج (CSV)", data=csv_bytes,
file_name="scoring_clients.csv", mime="text/csv")

خزّنّا النتيجة في session_state.resultat_lot (كما في الوحدة 6) لتبقى مرئيّة بعد إعادات التنفيذ التي يُنشّطها زرّ التنزيل.

الخلاصة

  • st.file_uploader يستقبل ملفًّا كذاكرة، بلا كتابة على قرص الخادم؛ نمرّره لـpandas مباشرة.
  • التحقّق من الأعمدة والأنواع والحجم قبل أيّ استعمال؛ رسائل خطأ واضحة تُخبر المستخدم كيف يُصلح.
  • التسجيل الدفعيّ مع st.spinner للمهام القصيرة، st.progress للمهام الأطول.
  • st.download_button مع utf-8-sig للـCSV العربيّ ليفتحه Excel صحيحًا.
  • حدّ الرفع الافتراضيّ 200 ميغابايت؛ ملفّات فوق 20 ميغابايت تستحقّ تخزينًا خارجيًّا لا Streamlit.

الوحدة التالية: استدعاء النموذج فعليًّا، محلّيًّا ثمّ عبر API بعيدة.